diff --git a/src/main/java/io/phasetwo/service/auth/idp/AlwaysSelectableIdentityProviderModel.java b/src/main/java/io/phasetwo/service/auth/idp/AlwaysSelectableIdentityProviderModel.java new file mode 100755 index 00000000..8dfe446d --- /dev/null +++ b/src/main/java/io/phasetwo/service/auth/idp/AlwaysSelectableIdentityProviderModel.java @@ -0,0 +1,27 @@ +//package de.sventorben.keycloak.authentication.hidpd; +package io.phasetwo.service.auth.idp; + +import org.keycloak.models.IdentityProviderModel; + +import java.util.HashMap; +import java.util.Map; + +final class AlwaysSelectableIdentityProviderModel extends IdentityProviderModel { + + AlwaysSelectableIdentityProviderModel(IdentityProviderModel delegate) { + super(delegate); + } + + @Override + public boolean isHideOnLogin() { + return false; + } + + @Override + public Map getConfig() { + Map superConfig = new HashMap<>(super.getConfig()); + superConfig.put("hideOnLoginPage", Boolean.FALSE.toString()); + return superConfig; + } + +} diff --git a/src/main/java/io/phasetwo/service/auth/idp/AuthenticationChallenge.java b/src/main/java/io/phasetwo/service/auth/idp/AuthenticationChallenge.java new file mode 100755 index 00000000..445e1f64 --- /dev/null +++ b/src/main/java/io/phasetwo/service/auth/idp/AuthenticationChallenge.java @@ -0,0 +1,49 @@ +//package de.sventorben.keycloak.authentication.hidpd; +package io.phasetwo.service.auth.idp; + +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import org.jboss.resteasy.specimpl.MultivaluedMapImpl; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.services.managers.AuthenticationManager; + +import java.util.List; + +final class AuthenticationChallenge { + + private final AuthenticationFlowContext context; + private final RememberMe rememberMe; + private final LoginHint loginHint; + private final LoginForm loginForm; + + AuthenticationChallenge(AuthenticationFlowContext context, RememberMe rememberMe, LoginHint loginHint, LoginForm loginForm) { + this.context = context; + this.rememberMe = rememberMe; + this.loginHint = loginHint; + this.loginForm = loginForm; + } + + void forceChallenge() { + MultivaluedMap formData = new MultivaluedMapImpl<>(); + String loginHintUsername = loginHint.getFromSession(); + + String rememberMeUsername = rememberMe.getUserName(); + + if (loginHintUsername != null || rememberMeUsername != null) { + if (loginHintUsername != null) { + formData.add(AuthenticationManager.FORM_USERNAME, loginHintUsername); + } else { + formData.add(AuthenticationManager.FORM_USERNAME, rememberMeUsername); + formData.add("rememberMe", "on"); + } + } + Response challengeResponse = loginForm.create(formData); + context.challenge(challengeResponse); + } + + void forceChallenge(List homeIdps) { + context.forceChallenge(loginForm.create(homeIdps)); + } + +} diff --git a/src/main/java/io/phasetwo/service/auth/idp/BaseUriLoginFormsProvider.java b/src/main/java/io/phasetwo/service/auth/idp/BaseUriLoginFormsProvider.java new file mode 100755 index 00000000..23e40023 --- /dev/null +++ b/src/main/java/io/phasetwo/service/auth/idp/BaseUriLoginFormsProvider.java @@ -0,0 +1,29 @@ +// package de.sventorben.keycloak.authentication.hidpd; +package io.phasetwo.service.auth.idp; + +import jakarta.ws.rs.core.UriBuilder; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.forms.login.freemarker.FreeMarkerLoginFormsProvider; +import org.keycloak.services.resources.LoginActionsService; + +import java.net.URI; + +/** + * Workaround to reuse the logic in FreeMarkerLoginFormsProvider.prepareBaseUriBuilder, so no need to reimplement it. + */ +final class BaseUriLoginFormsProvider extends FreeMarkerLoginFormsProvider { + + public BaseUriLoginFormsProvider(AuthenticationFlowContext context) { + super(context.getSession()); + super.setAuthenticationSession(context.getAuthenticationSession()); + super.setClientSessionCode(context.generateAccessCode()); + } + + public URI getBaseUriWithCodeAndClientId() { + UriBuilder baseUriBuilder = super.prepareBaseUriBuilder(false); + if (accessCode != null) { + baseUriBuilder.queryParam(LoginActionsService.SESSION_CODE, accessCode); + } + return baseUriBuilder.build(); + } +} diff --git a/src/main/java/io/phasetwo/service/auth/idp/Domain.java b/src/main/java/io/phasetwo/service/auth/idp/Domain.java new file mode 100644 index 00000000..9c6890f4 --- /dev/null +++ b/src/main/java/io/phasetwo/service/auth/idp/Domain.java @@ -0,0 +1,39 @@ +//package de.sventorben.keycloak.authentication.hidpd; +package io.phasetwo.service.auth.idp; + +import java.util.Objects; + +class Domain { + + private final String value; + + Domain(String value) { + Objects.requireNonNull(value); + this.value = value.toLowerCase(); + } + + boolean isSubDomainOf(Domain domain) { + return this.value.endsWith("." + domain.value); + } + + @Override + public boolean equals(Object obj) { + if (obj == null) + return false; + if (!(obj instanceof Domain)) + return false; + if (this == obj) + return true; + return this.value.equalsIgnoreCase(((Domain) obj).value); + } + + @Override + public int hashCode() { + return this.value.hashCode(); + } + + @Override + public String toString() { + return this.value; + } +} diff --git a/src/main/java/io/phasetwo/service/auth/idp/DomainExtractor.java b/src/main/java/io/phasetwo/service/auth/idp/DomainExtractor.java new file mode 100755 index 00000000..aabf2ecc --- /dev/null +++ b/src/main/java/io/phasetwo/service/auth/idp/DomainExtractor.java @@ -0,0 +1,51 @@ +//package de.sventorben.keycloak.authentication.hidpd; +package io.phasetwo.service.auth.idp; + +import org.jboss.logging.Logger; +import org.keycloak.models.UserModel; + +import java.util.Optional; + +final class DomainExtractor { + + private static final Logger LOG = Logger.getLogger(DomainExtractor.class); + private static final String EMAIL_ATTRIBUTE = "email"; + + private final HomeIdpDiscoveryConfig config; + + DomainExtractor(HomeIdpDiscoveryConfig config) { + this.config = config; + } + + Optional extractFrom(UserModel user) { + if (!user.isEnabled()) { + LOG.warnf("User '%s' not enabled", user.getId()); + return Optional.empty(); + } + String userAttribute = user.getFirstAttribute(config.userAttribute()); + if (userAttribute == null) { + LOG.warnf("Could not find user attribute '%s' for user '%s'", config.userAttribute(), user.getId()); + return Optional.empty(); + } + if (EMAIL_ATTRIBUTE.equalsIgnoreCase(config.userAttribute()) && !user.isEmailVerified()) { + LOG.warnf("Email address of user '%s' is not verified", user.getId()); + return Optional.empty(); + } + return extractFrom(userAttribute); + } + + Optional extractFrom(String usernameOrEmail) { + Domain domain = null; + if (usernameOrEmail != null) { + int atIndex = usernameOrEmail.trim().lastIndexOf("@"); + if (atIndex >= 0) { + String strDomain = usernameOrEmail.trim().substring(atIndex + 1); + if (strDomain.length() > 0) { + domain = new Domain(strDomain); + } + } + } + return Optional.ofNullable(domain); + } + +} diff --git a/src/main/java/io/phasetwo/service/auth/idp/HomeIdpAuthenticationFlowContext.java b/src/main/java/io/phasetwo/service/auth/idp/HomeIdpAuthenticationFlowContext.java new file mode 100755 index 00000000..c94d9f69 --- /dev/null +++ b/src/main/java/io/phasetwo/service/auth/idp/HomeIdpAuthenticationFlowContext.java @@ -0,0 +1,85 @@ +//package de.sventorben.keycloak.authentication.hidpd; +package io.phasetwo.service.auth.idp; + +import org.keycloak.authentication.AuthenticationFlowContext; + +final class HomeIdpAuthenticationFlowContext { + + private final AuthenticationFlowContext context; + private HomeIdpDiscoveryConfig config; + private LoginPage loginPage; + private LoginHint loginHint; + private HomeIdpDiscoverer discoverer; + private RememberMe rememberMe; + private AuthenticationChallenge authenticationChallenge; + private Redirector redirector; + private BaseUriLoginFormsProvider loginFormsProvider; + private LoginForm loginForm; + + HomeIdpAuthenticationFlowContext(AuthenticationFlowContext context) { + this.context = context; + } + + HomeIdpDiscoveryConfig config() { + if (config == null) { + config = new HomeIdpDiscoveryConfig(context.getAuthenticatorConfig()); + } + return config; + } + + LoginPage loginPage() { + if (loginPage == null) { + loginPage = new LoginPage(context, config()); + } + return loginPage; + } + + LoginHint loginHint() { + if (loginHint == null) { + loginHint = new LoginHint(context); + } + return loginHint; + } + + HomeIdpDiscoverer discoverer() { + if (discoverer == null) { + discoverer = new HomeIdpDiscoverer(context); + } + return discoverer; + } + + RememberMe rememberMe() { + if (rememberMe == null) { + rememberMe = new RememberMe(context); + } + return rememberMe; + } + + AuthenticationChallenge authenticationChallenge() { + if (authenticationChallenge == null) { + authenticationChallenge = new AuthenticationChallenge(context, rememberMe(), loginHint(), loginForm()); + } + return authenticationChallenge; + } + + Redirector redirector() { + if (redirector == null) { + redirector = new Redirector(context); + } + return redirector; + } + + LoginForm loginForm() { + if (loginForm == null) { + loginForm = new LoginForm(context, loginFormsProvider()); + } + return loginForm; + } + + BaseUriLoginFormsProvider loginFormsProvider() { + if (loginFormsProvider == null) { + loginFormsProvider = new BaseUriLoginFormsProvider(context); + } + return loginFormsProvider; + } +} diff --git a/src/main/java/io/phasetwo/service/auth/idp/HomeIdpDiscoverer.java b/src/main/java/io/phasetwo/service/auth/idp/HomeIdpDiscoverer.java new file mode 100755 index 00000000..8e43ca46 --- /dev/null +++ b/src/main/java/io/phasetwo/service/auth/idp/HomeIdpDiscoverer.java @@ -0,0 +1,171 @@ +//package de.sventorben.keycloak.authentication.hidpd; +package io.phasetwo.service.auth.idp; + +import io.phasetwo.service.model.OrganizationProvider; +import org.jboss.logging.Logger; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.FederatedIdentityModel; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +final class HomeIdpDiscoverer { + + private static final Logger LOG = Logger.getLogger(HomeIdpDiscoverer.class); + + private final DomainExtractor domainExtractor; + private final AuthenticationFlowContext context; + + HomeIdpDiscoverer(AuthenticationFlowContext context) { + this(new DomainExtractor(new HomeIdpDiscoveryConfig(context.getAuthenticatorConfig())), context); + } + + private HomeIdpDiscoverer(DomainExtractor domainExtractor, AuthenticationFlowContext context) { + this.domainExtractor = domainExtractor; + this.context = context; + } + + public List discoverForUser(String username) { + + String realmName = context.getRealm().getName(); + AuthenticatorConfigModel authenticatorConfig = context.getAuthenticatorConfig(); + LOG.tracef("Trying to discover home IdP for username '%s' in realm '%s' with authenticator config '%s'", + username, realmName, authenticatorConfig == null ? "" : authenticatorConfig.getAlias()); + + List homeIdps = new ArrayList<>(); + + final Optional emailDomain; + UserModel user = context.getUser(); + if (user == null) { + LOG.tracef("No user found in AuthenticationFlowContext. Extracting domain from provided username '%s'.", + username); + emailDomain = domainExtractor.extractFrom(username); + } else { + LOG.tracef("User found in AuthenticationFlowContext. Extracting domain from stored user '%s'.", + user.getId()); + emailDomain = domainExtractor.extractFrom(user); + } + + HomeIdpDiscoveryConfig config = new HomeIdpDiscoveryConfig(authenticatorConfig); + if (config.requireVerifiedEmail() + && "email".equalsIgnoreCase(config.userAttribute()) + && !user.isEmailVerified()) { + LOG.infof("Email of user %s not verified. Skipping discovery of linked IdPs", user.getId()); + return homeIdps; + } + + if (emailDomain.isPresent()) { + Domain domain = emailDomain.get(); + homeIdps = discoverHomeIdps(domain, user, username); + if (homeIdps.isEmpty()) { + LOG.infof("Could not find home IdP for domain '%s' and user '%s' in realm '%s'", + domain, username, realmName); + } + } else { + LOG.warnf("Could not extract domain from email address '%s'", username); + } + + return homeIdps; + } + + private List discoverHomeIdps(Domain domain, UserModel user, String username) { + final Map linkedIdps; + + HomeIdpDiscoveryConfig config = new HomeIdpDiscoveryConfig(context.getAuthenticatorConfig()); + if (user == null || !config.forwardToLinkedIdp()) { + linkedIdps = Collections.emptyMap(); + LOG.tracef( + "User '%s' is not stored locally or forwarding to linked IdP is disabled. Skipping discovery of linked IdPs.", + username); + } else { + LOG.tracef( + "Found local user '%s' and forwarding to linked IdP is enabled. Discovering linked IdPs.", + username); + linkedIdps = context.getSession().users() + .getFederatedIdentitiesStream(context.getRealm(), user) + .collect( + Collectors.toMap(FederatedIdentityModel::getIdentityProvider, FederatedIdentityModel::getUserName)); + } + + List enabledIdps = determineEnabledIdps(); + // Original; lookup mechanism from https://github.com/sventorben/keycloak-home-idp-discovery + /* + List enabledIdpsWithMatchingDomain = filterIdpsWithMatchingDomainFrom(enabledIdps, + domain, + config); + */ + // Overidden lookup mechanism to lookup via organization domain + OrganizationProvider orgs = context.getSession().getProvider(OrganizationProvider.class); + List enabledIdpsWithMatchingDomain = + orgs.getOrganizationsStreamForDomain( + context.getRealm(), domain.toString(), config.requireVerifiedDomain()) + .flatMap(o -> o.getIdentityProvidersStream()) + .filter(IdentityProviderModel::isEnabled) + .collect(Collectors.toList()); + + // Prefer linked IdP with matching domain first + List homeIdps = getLinkedIdpsFrom(enabledIdpsWithMatchingDomain, linkedIdps); + + if (homeIdps.isEmpty()) { + if (!linkedIdps.isEmpty()) { + // Prefer linked and enabled IdPs without matching domain in favor of not linked IdPs with matching domain + homeIdps = getLinkedIdpsFrom(enabledIdps, linkedIdps); + } + if (homeIdps.isEmpty()) { + // Fallback to not linked IdPs with matching domain (general case if user logs in for the first time) + homeIdps = enabledIdpsWithMatchingDomain; + logFoundIdps("non-linked", "matching", homeIdps, domain, username); + } else { + logFoundIdps("non-linked", "non-matching", homeIdps, domain, username); + } + } else { + logFoundIdps("linked", "matching", homeIdps, domain, username); + } + + return homeIdps; + } + + private void logFoundIdps(String idpQualifier, String domainQualifier, List homeIdps, Domain domain, String username) { + String homeIdpsString = homeIdps.stream() + .map(IdentityProviderModel::getAlias) + .collect(Collectors.joining(",")); + LOG.tracef("Found %s IdPs [%s] with %s domain '%s' for user '%s'", + idpQualifier, homeIdpsString, domainQualifier, domain, username); + } + + private List getLinkedIdpsFrom(List enabledIdpsWithMatchingDomain, Map linkedIdps) { + return enabledIdpsWithMatchingDomain.stream() + .filter(it -> linkedIdps.containsKey(it.getAlias())) + .collect(Collectors.toList()); + } + + private List filterIdpsWithMatchingDomainFrom(List enabledIdps, Domain domain, HomeIdpDiscoveryConfig config) { + String userAttributeName = config.userAttribute(); + List idpsWithMatchingDomain = enabledIdps.stream() + .filter(it -> new IdentityProviderModelConfig(it).supportsDomain(userAttributeName, domain)) + .collect(Collectors.toList()); + LOG.tracef("IdPs with matching domain '%s' for attribute '%s': %s", domain, userAttributeName, + idpsWithMatchingDomain.stream().map(IdentityProviderModel::getAlias).collect(Collectors.joining(","))); + return idpsWithMatchingDomain; + } + + private List determineEnabledIdps() { + RealmModel realm = context.getRealm(); + List enabledIdps = realm.getIdentityProvidersStream() + .filter(IdentityProviderModel::isEnabled) + .collect(Collectors.toList()); + LOG.tracef("Enabled IdPs in realm '%s': %s", + realm.getName(), + enabledIdps.stream().map(IdentityProviderModel::getAlias).collect(Collectors.joining(","))); + return enabledIdps; + } + +} diff --git a/src/main/java/io/phasetwo/service/auth/idp/HomeIdpDiscoveryAuthenticator.java b/src/main/java/io/phasetwo/service/auth/idp/HomeIdpDiscoveryAuthenticator.java index 717a136e..8863181a 100755 --- a/src/main/java/io/phasetwo/service/auth/idp/HomeIdpDiscoveryAuthenticator.java +++ b/src/main/java/io/phasetwo/service/auth/idp/HomeIdpDiscoveryAuthenticator.java @@ -1,326 +1,159 @@ -// package de.sventorben.keycloak.authentication.hidpd; +//package de.sventorben.keycloak.authentication.hidpd; package io.phasetwo.service.auth.idp; -import static org.keycloak.services.validation.Validation.FIELD_USERNAME; - -import io.phasetwo.service.model.OrganizationProvider; import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.Response; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; import org.jboss.logging.Logger; -import org.jboss.resteasy.specimpl.MultivaluedMapImpl; import org.keycloak.authentication.AuthenticationFlowContext; import org.keycloak.authentication.AuthenticationFlowError; import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator; import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.forms.login.LoginFormsProvider; -import org.keycloak.models.AuthenticationExecutionModel; -import org.keycloak.models.FederatedIdentityModel; import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.ModelDuplicateException; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.utils.KeycloakModelUtils; -import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.services.managers.AuthenticationManager; -final class HomeIdpDiscoveryAuthenticator extends AbstractUsernameFormAuthenticator { - - private static final Logger LOG = Logger.getLogger(HomeIdpDiscoveryAuthenticator.class); - - HomeIdpDiscoveryAuthenticator() {} - - @Override - public void authenticate(AuthenticationFlowContext context) { - String attemptedUsername = getAttemptedUsername(context); - if (attemptedUsername == null) { - challenge(context); - } else { - LOG.info("Found attempted username from previous authenticator, skipping login form"); - if (context.getExecution().getRequirement() - == AuthenticationExecutionModel.Requirement.REQUIRED) { - action(context); - } else { - context.attempted(); - } - } - } - - private String getAttemptedUsername(AuthenticationFlowContext context) { - return trimToNull(context.getAuthenticationSession().getAuthNote(ATTEMPTED_USERNAME)); - } - - private void challenge(AuthenticationFlowContext context) { - MultivaluedMap formData = new MultivaluedMapImpl<>(); - String loginHint = - context.getAuthenticationSession().getClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM); - - String rememberMeUsername = - AuthenticationManager.getRememberMeUsername( - context.getRealm(), context.getHttpRequest().getHttpHeaders()); - - if (loginHint != null || rememberMeUsername != null) { - if (loginHint != null) { - formData.add(AuthenticationManager.FORM_USERNAME, loginHint); - } else { - formData.add(AuthenticationManager.FORM_USERNAME, rememberMeUsername); - formData.add("rememberMe", "on"); - } - } - Response challengeResponse = challenge(context, formData); - context.challenge(challengeResponse); - } - - @Override - public void action(AuthenticationFlowContext context) { - LOG.info("home idp discovery action"); - MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); - if (formData.containsKey("cancel")) { - context.cancelLogin(); - return; - } - - String username = setUserInContext(context, formData); - if (username == null) { - return; - } - - final Optional homeIdp = discoverHomeIdp(context, username); - - if (homeIdp.isEmpty()) { - // context.attempted(); - if (context.getExecution().getRequirement() - == AuthenticationExecutionModel.Requirement.REQUIRED) { - context.success(); - } else { - context.attempted(); - } - } else { - new Redirector(context).redirectTo(homeIdp.get()); - } - } +import java.util.List; - private String setUserInContext( - AuthenticationFlowContext context, MultivaluedMap inputData) { - context.clearUser(); +import static org.keycloak.protocol.oidc.OIDCLoginProtocol.LOGIN_HINT_PARAM; +import static org.keycloak.services.validation.Validation.FIELD_USERNAME; - String username = trimToNull(inputData.getFirst(AuthenticationManager.FORM_USERNAME)); +final class HomeIdpDiscoveryAuthenticator extends AbstractUsernameFormAuthenticator { - if (username == null) { - LOG.info( - "Could not find username in request. Trying attempted username from previous authenticator"); - username = getAttemptedUsername(context); + private static final Logger LOG = Logger.getLogger(HomeIdpDiscoveryAuthenticator.class); + + HomeIdpDiscoveryAuthenticator() { + } + + @Override + public void authenticate(AuthenticationFlowContext authenticationFlowContext) { + HomeIdpAuthenticationFlowContext context = new HomeIdpAuthenticationFlowContext(authenticationFlowContext); + + if (context.loginPage().shouldByPass()) { + String loginHint = trimToNull(context.loginHint().getFromSession()); + if (loginHint == null) { + loginHint = trimToNull(authenticationFlowContext.getAuthenticationSession().getAuthNote(ATTEMPTED_USERNAME)); + } + if (loginHint != null) { + String username = setUserInContext(authenticationFlowContext, loginHint); + final List homeIdps = context.discoverer().discoverForUser(username); + if (!homeIdps.isEmpty()) { + context.rememberMe().remember(username); + redirectOrChallenge(context, username, homeIdps); + return; + } + } + } + context.authenticationChallenge().forceChallenge(); } - if (username == null) { - context.getEvent().error(Errors.USER_NOT_FOUND); - Response challengeResponse = - challenge(context, getDefaultChallengeMessage(context), FIELD_USERNAME); - context.failureChallenge(AuthenticationFlowError.INVALID_USER, challengeResponse); - return null; + private void redirectOrChallenge(HomeIdpAuthenticationFlowContext context, String username, List homeIdps) { + if (homeIdps.size() == 1 || context.config().forwardToFirstMatch()) { + IdentityProviderModel homeIdp = homeIdps.get(0); + context.loginHint().setInAuthSession(homeIdp, username); + context.redirector().redirectTo(homeIdp); + } else { + context.authenticationChallenge().forceChallenge(homeIdps); + } } - context.getEvent().detail(Details.USERNAME, username); - context - .getAuthenticationSession() - .setAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME, username); - context.getAuthenticationSession().setClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM, username); + @Override + public void action(AuthenticationFlowContext authenticationFlowContext) { + MultivaluedMap formData = authenticationFlowContext.getHttpRequest().getDecodedFormParameters(); + if (formData.containsKey("cancel")) { + LOG.debugf("Login canceled"); + authenticationFlowContext.cancelLogin(); + return; + } - try { - UserModel user = - KeycloakModelUtils.findUserByNameOrEmail( - context.getSession(), context.getRealm(), username); - if (user != null) { - context.setUser(user); - } - } catch (ModelDuplicateException ex) { - LOG.debugf(ex, "Could not find user %s", username); - } + String username = setUserInContext(authenticationFlowContext, formData.getFirst(AuthenticationManager.FORM_USERNAME)); + if (username == null) { + LOG.debugf("No username in request"); + return; + } - return username; - } + HomeIdpAuthenticationFlowContext context = new HomeIdpAuthenticationFlowContext(authenticationFlowContext); - private Response challenge( - AuthenticationFlowContext context, MultivaluedMap formData) { - LoginFormsProvider forms = context.form(); - if (!formData.isEmpty()) { - forms.setFormData(formData); + final List homeIdps = context.discoverer().discoverForUser(username); + if (homeIdps.isEmpty()) { + authenticationFlowContext.attempted(); + } else { + RememberMe rememberMe = context.rememberMe(); + rememberMe.handleAction(formData); + rememberMe.remember(username); + redirectOrChallenge(context, username, homeIdps); + } } - return forms.createLoginUsername(); - } - @Override - protected Response createLoginForm(LoginFormsProvider form) { - return form.createLoginUsername(); - } + private String setUserInContext(AuthenticationFlowContext context, String username) { + context.clearUser(); - @Override - protected String getDefaultChallengeMessage(AuthenticationFlowContext context) { - return context.getRealm().isLoginWithEmailAllowed() - ? "invalidUsernameOrEmailMessage" - : "invalidUsernameMessage"; - } + username = trimToNull(username); - private static Optional discoverHomeIdp( - AuthenticationFlowContext context, String username) { - Optional homeIdp = Optional.empty(); + if (username == null) { + LOG.warn("No or empty username found in request"); + context.getEvent().error(Errors.USER_NOT_FOUND); + Response challengeResponse = challenge(context, getDefaultChallengeMessage(context), FIELD_USERNAME); + context.failureChallenge(AuthenticationFlowError.INVALID_USER, challengeResponse); + return null; + } - final Optional emailDomain; - UserModel user = context.getUser(); - if (user == null) { - emailDomain = getEmailDomain(username); - LOG.infof("emailDomain = %s for unknown user %s", emailDomain.orElse("(unknown)"), username); - } else { - HomeIdpDiscoveryConfig config = new HomeIdpDiscoveryConfig(context.getAuthenticatorConfig()); - emailDomain = getEmailDomain(user, config); - LOG.infof("emailDomain = %s for known user %s", emailDomain.orElse("(unknown)"), username); - } + LOG.debugf("Found username '%s' in request", username); + context.getEvent().detail(Details.USERNAME, username); + context.getAuthenticationSession().setAuthNote(ATTEMPTED_USERNAME, username); + context.getAuthenticationSession().setClientNote(LOGIN_HINT_PARAM, username); + + try { + UserModel user = KeycloakModelUtils.findUserByNameOrEmail(context.getSession(), context.getRealm(), + username); + if (user != null) { + LOG.tracef("Setting user '%s' in context", user.getId()); + context.setUser(user); + } + } catch (ModelDuplicateException ex) { + LOG.warnf(ex, "Could not uniquely identify the user. Multiple users with name or email '%s' found.", + username); + } - if (emailDomain.isPresent()) { - String domain = emailDomain.get(); - homeIdp = discoverHomeIdp(context, domain, user, username); - if (homeIdp.isEmpty()) { - LOG.tracef("Could not find home IdP for domain %s and user %s", domain, username); - } - } else { - LOG.warnf("Could not extract domain from email address %s", username); + return username; } - return homeIdp; - } - - private static Optional discoverHomeIdp( - AuthenticationFlowContext context, String domain, UserModel user, String username) { - final Map linkedIdps; - - HomeIdpDiscoveryConfig config = new HomeIdpDiscoveryConfig(context.getAuthenticatorConfig()); - if (user == null || !config.forwardToLinkedIdp()) { - linkedIdps = Collections.emptyMap(); - } else { - linkedIdps = - context - .getSession() - .users() - .getFederatedIdentitiesStream(context.getRealm(), user) - .collect( - Collectors.toMap( - FederatedIdentityModel::getIdentityProvider, - FederatedIdentityModel::getUserName)); + private String trimToNull(String username) { + if (username != null) { + username = username.trim(); + if ("".equalsIgnoreCase(username)) + username = null; + } + return username; } - // enabled IdPs with domain - /* - List idpsWithDomain = context.getRealm().getIdentityProvidersStream() - .filter(IdentityProviderModel::isEnabled) - .filter(it -> new IdentityProviderModelConfig(it).hasDomain(config.userAttribute(), domain)) - .collect(Collectors.toList()); - */ - - OrganizationProvider orgs = context.getSession().getProvider(OrganizationProvider.class); - List idpsWithDomain = - orgs.getOrganizationsStreamForDomain( - context.getRealm(), domain, config.requireVerifiedDomain()) - .flatMap(o -> o.getIdentityProvidersStream()) - .filter(IdentityProviderModel::isEnabled) - .collect(Collectors.toList()); - LOG.infof("Found %d idpsWithDomain %s", idpsWithDomain.size(), domain); - - // Linked IdPs with matching domain - Optional homeIdp = - idpsWithDomain.stream().filter(it -> linkedIdps.containsKey(it.getAlias())).findFirst(); - - // linked and enabled IdPs - if (homeIdp.isEmpty() && !linkedIdps.isEmpty()) { - homeIdp = - context - .getRealm() - .getIdentityProvidersStream() - .filter(IdentityProviderModel::isEnabled) - .filter(it -> linkedIdps.containsKey(it.getAlias())) - .findFirst(); + @Override + protected Response createLoginForm(LoginFormsProvider form) { + return form.createLoginUsername(); } - // Matching domain - if (homeIdp.isEmpty()) { - homeIdp = idpsWithDomain.stream().findFirst(); + @Override + protected String getDefaultChallengeMessage(AuthenticationFlowContext context) { + return context.getRealm().isLoginWithEmailAllowed() ? "invalidUsernameOrEmailMessage" : "invalidUsernameMessage"; } - homeIdp.ifPresent( - it -> { - if (linkedIdps.containsKey(it.getAlias()) && config.forwardToLinkedIdp()) { - String idpUsername = linkedIdps.get(it.getAlias()); - context - .getAuthenticationSession() - .setClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM, idpUsername); - } else { - context - .getAuthenticationSession() - .setClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM, username); - } - }); - - return homeIdp; - } - - private static Optional getEmailDomain(UserModel user, HomeIdpDiscoveryConfig config) { - if (!user.isEnabled()) { - LOG.debugf("User %s not enabled", user.getId()); - return Optional.empty(); + @Override + public boolean requiresUser() { + return false; } - String userAttribute = user.getFirstAttribute(config.userAttribute()); - if (userAttribute == null) { - LOG.debugf( - "Could not find user attribute %s for user %s", config.userAttribute(), user.getId()); - return Optional.empty(); - } - if (config.requireVerifiedEmail() - && "email".equalsIgnoreCase(config.userAttribute()) - && !user.isEmailVerified()) { - LOG.infof("Email of user %s not verified", user.getId()); - return Optional.empty(); - } - return getEmailDomain(userAttribute); - } - private static Optional getEmailDomain(String email) { - String domain = null; - if (email != null) { - int atIndex = email.trim().lastIndexOf("@"); - if (atIndex >= 0) { - domain = email.substring(atIndex + 1).trim(); - if (domain.length() == 0) { - domain = null; - } - } + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; } - return Optional.ofNullable(domain); - } - private static String trimToNull(final String string) { - if (string == null) { - return null; + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { } - String trimmed = string.trim(); - if ("".equalsIgnoreCase(trimmed)) trimmed = null; - return trimmed; - } - - @Override - public boolean requiresUser() { - return false; - } - - @Override - public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { - return true; - } - @Override - public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {} } diff --git a/src/main/java/io/phasetwo/service/auth/idp/HomeIdpDiscoveryAuthenticatorFactory.java b/src/main/java/io/phasetwo/service/auth/idp/HomeIdpDiscoveryAuthenticatorFactory.java index 7d2410e0..263c9768 100755 --- a/src/main/java/io/phasetwo/service/auth/idp/HomeIdpDiscoveryAuthenticatorFactory.java +++ b/src/main/java/io/phasetwo/service/auth/idp/HomeIdpDiscoveryAuthenticatorFactory.java @@ -1,11 +1,7 @@ -// package de.sventorben.keycloak.authentication.hidpd; +//package de.sventorben.keycloak.authentication.hidpd; package io.phasetwo.service.auth.idp; -import static org.keycloak.models.AuthenticationExecutionModel.Requirement.*; - import com.google.auto.service.AutoService; -import java.util.List; -import java.util.Map; import org.jboss.logging.Logger; import org.keycloak.Config; import org.keycloak.authentication.Authenticator; @@ -16,81 +12,87 @@ import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.provider.ServerInfoAwareProviderFactory; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.keycloak.models.AuthenticationExecutionModel.Requirement.*; + @AutoService(AuthenticatorFactory.class) -public final class HomeIdpDiscoveryAuthenticatorFactory - implements AuthenticatorFactory, ServerInfoAwareProviderFactory { - - private static final Logger LOG = Logger.getLogger(HomeIdpDiscoveryAuthenticatorFactory.class); - - private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = - new AuthenticationExecutionModel.Requirement[] {REQUIRED, ALTERNATIVE, DISABLED}; - - private static final String PROVIDER_ID = "ext-auth-home-idp-discovery"; - - private Config.Scope config; - - @Override - public String getDisplayType() { - return "Home IdP Discovery"; - } - - @Override - public String getReferenceCategory() { - return "Authorization"; - } - - @Override - public boolean isConfigurable() { - return true; - } - - @Override - public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { - return REQUIREMENT_CHOICES; - } - - @Override - public boolean isUserSetupAllowed() { - return false; - } - - @Override - public String getHelpText() { - return "Redirects you to your home identity provider"; - } - - @Override - public List getConfigProperties() { - return HomeIdpDiscoveryConfigProperties.CONFIG_PROPERTIES; - } - - @Override - public Authenticator create(KeycloakSession session) { - return new HomeIdpDiscoveryAuthenticator(); - } - - @Override - public void init(Config.Scope config) { - this.config = config; - } - - @Override - public void postInit(KeycloakSessionFactory factory) {} - - @Override - public void close() {} - - @Override - public String getId() { - return PROVIDER_ID; - } - - @Override - public Map getOperationalInfo() { - String version = getClass().getPackage().getImplementationVersion(); - if (version == null) { - version = "dev-snapshot"; +public final class HomeIdpDiscoveryAuthenticatorFactory implements AuthenticatorFactory, ServerInfoAwareProviderFactory { + + private static final Logger LOG = Logger.getLogger(HomeIdpDiscoveryAuthenticatorFactory.class); + + private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = new AuthenticationExecutionModel.Requirement[]{REQUIRED, ALTERNATIVE, DISABLED}; + + private static final String PROVIDER_ID = "ext-auth-home-idp-discovery"; + + private Config.Scope config; + + @Override + public String getDisplayType() { + return "Home IdP Discovery"; + } + + @Override + public String getReferenceCategory() { + return "Authorization"; + } + + @Override + public boolean isConfigurable() { + return true; + } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public String getHelpText() { + return "Redirects users to their home identity provider"; + } + + @Override + public List getConfigProperties() { + return HomeIdpDiscoveryConfigProperties.CONFIG_PROPERTIES; + } + + @Override + public Authenticator create(KeycloakSession session) { + return new HomeIdpDiscoveryAuthenticator(); + } + + @Override + public void init(Config.Scope config) { + this.config = config; + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public Map getOperationalInfo() { + String version = getClass().getPackage().getImplementationVersion(); + if (version == null) { + version = "dev-snapshot"; + } + return Map.of("Version", version); } - return Map.of("Version", version); - } } diff --git a/src/main/java/io/phasetwo/service/auth/idp/HomeIdpDiscoveryConfig.java b/src/main/java/io/phasetwo/service/auth/idp/HomeIdpDiscoveryConfig.java index ba83eecb..66b1d911 100755 --- a/src/main/java/io/phasetwo/service/auth/idp/HomeIdpDiscoveryConfig.java +++ b/src/main/java/io/phasetwo/service/auth/idp/HomeIdpDiscoveryConfig.java @@ -1,48 +1,62 @@ -// package de.sventorben.keycloak.authentication.hidpd; +//package de.sventorben.keycloak.authentication.hidpd; package io.phasetwo.service.auth.idp; -import java.util.Optional; import org.keycloak.models.AuthenticatorConfigModel; +import java.util.Optional; + final class HomeIdpDiscoveryConfig { - static final String REQUIRE_VERIFIED_EMAIL = "requireVerifiedEmail"; - static final String REQUIRE_VERIFIED_DOMAIN = "requireVerifiedDomain"; - static final String FORWARD_TO_LINKED_IDP = "forwardToLinkedIdp"; - static final String USER_ATTRIBUTE = "userAttribute"; + static final String REQUIRE_VERIFIED_EMAIL = "requireVerifiedEmail"; + static final String REQUIRE_VERIFIED_DOMAIN = "requireVerifiedDomain"; + static final String FORWARD_TO_LINKED_IDP = "forwardToLinkedIdp"; + static final String BYPASS_LOGIN_PAGE = "bypassLoginPage"; + static final String USER_ATTRIBUTE = "userAttribute"; + static final String FORWARD_TO_FIRST_MATCH = "forwardToFirstMatch"; - private final AuthenticatorConfigModel authenticatorConfigModel; + private final AuthenticatorConfigModel authenticatorConfigModel; - HomeIdpDiscoveryConfig(AuthenticatorConfigModel authenticatorConfigModel) { - this.authenticatorConfigModel = authenticatorConfigModel; - } + HomeIdpDiscoveryConfig(AuthenticatorConfigModel authenticatorConfigModel) { + this.authenticatorConfigModel = authenticatorConfigModel; + } - boolean requireVerifiedEmail() { + boolean requireVerifiedEmail() { return Optional.ofNullable(authenticatorConfigModel) .map( it -> Boolean.parseBoolean(it.getConfig().getOrDefault(REQUIRE_VERIFIED_EMAIL, "false"))) .orElse(false); - } - - boolean requireVerifiedDomain() { - return Optional.ofNullable(authenticatorConfigModel) - .map( - it -> - Boolean.parseBoolean(it.getConfig().getOrDefault(REQUIRE_VERIFIED_DOMAIN, "false"))) - .orElse(false); - } - - boolean forwardToLinkedIdp() { - return Optional.ofNullable(authenticatorConfigModel) - .map( - it -> Boolean.parseBoolean(it.getConfig().getOrDefault(FORWARD_TO_LINKED_IDP, "false"))) - .orElse(false); - } - - String userAttribute() { - return Optional.ofNullable(authenticatorConfigModel) - .map(it -> it.getConfig().getOrDefault(USER_ATTRIBUTE, "email").trim()) - .orElse("email"); - } + } + + boolean requireVerifiedDomain() { + return Optional.ofNullable(authenticatorConfigModel) + .map( + it -> + Boolean.parseBoolean(it.getConfig().getOrDefault(REQUIRE_VERIFIED_DOMAIN, "false"))) + .orElse(false); + } + + boolean forwardToLinkedIdp() { + return Optional.ofNullable(authenticatorConfigModel) + .map(it -> Boolean.parseBoolean(it.getConfig().getOrDefault(FORWARD_TO_LINKED_IDP, "false"))) + .orElse(false); + } + + boolean bypassLoginPage() { + return Optional.ofNullable(authenticatorConfigModel) + .map(it -> Boolean.parseBoolean(it.getConfig().getOrDefault(BYPASS_LOGIN_PAGE, "false"))) + .orElse(false); + } + + String userAttribute() { + return Optional.ofNullable(authenticatorConfigModel) + .map(it -> it.getConfig().getOrDefault(USER_ATTRIBUTE, "email").trim()) + .orElse("email"); + } + + boolean forwardToFirstMatch() { + return Optional.ofNullable(authenticatorConfigModel) + .map(it -> Boolean.parseBoolean(it.getConfig().getOrDefault(FORWARD_TO_FIRST_MATCH, "true"))) + .orElse(true); + } } diff --git a/src/main/java/io/phasetwo/service/auth/idp/HomeIdpDiscoveryConfigProperties.java b/src/main/java/io/phasetwo/service/auth/idp/HomeIdpDiscoveryConfigProperties.java index af5bb867..f820cd16 100755 --- a/src/main/java/io/phasetwo/service/auth/idp/HomeIdpDiscoveryConfigProperties.java +++ b/src/main/java/io/phasetwo/service/auth/idp/HomeIdpDiscoveryConfigProperties.java @@ -1,56 +1,79 @@ -// package de.sventorben.keycloak.authentication.hidpd; +//package de.sventorben.keycloak.authentication.hidpd; package io.phasetwo.service.auth.idp; -import static org.keycloak.provider.ProviderConfigProperty.BOOLEAN_TYPE; -import static org.keycloak.provider.ProviderConfigProperty.STRING_TYPE; - -import java.util.List; import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.provider.ProviderConfigurationBuilder; -final class HomeIdpDiscoveryConfigProperties { +import java.util.List; - private static final ProviderConfigProperty FORWARD_TO_LINKED_IDP_PROPERTY = - new ProviderConfigProperty( - HomeIdpDiscoveryConfig.FORWARD_TO_LINKED_IDP, - "Forward to linked IdP", - "Whether to forward existing user to a linked identity provider or not.", - BOOLEAN_TYPE, - false, - false); +import static io.phasetwo.service.auth.idp.HomeIdpDiscoveryConfig.REQUIRE_VERIFIED_EMAIL; +import static io.phasetwo.service.auth.idp.HomeIdpDiscoveryConfig.REQUIRE_VERIFIED_DOMAIN; +import static io.phasetwo.service.auth.idp.HomeIdpDiscoveryConfig.BYPASS_LOGIN_PAGE; +import static io.phasetwo.service.auth.idp.HomeIdpDiscoveryConfig.FORWARD_TO_LINKED_IDP; +import static io.phasetwo.service.auth.idp.HomeIdpDiscoveryConfig.FORWARD_TO_FIRST_MATCH; +import static io.phasetwo.service.auth.idp.HomeIdpDiscoveryConfig.USER_ATTRIBUTE; +import static org.keycloak.provider.ProviderConfigProperty.BOOLEAN_TYPE; +import static org.keycloak.provider.ProviderConfigProperty.STRING_TYPE; - private static final ProviderConfigProperty REQUIRE_VERIFIED_EMAIL_PROPERTY = +final class HomeIdpDiscoveryConfigProperties { + + private static final ProviderConfigProperty REQUIRE_VERIFIED_EMAIL_PROPERTY = new ProviderConfigProperty( - HomeIdpDiscoveryConfig.REQUIRE_VERIFIED_EMAIL, + REQUIRE_VERIFIED_EMAIL, "Require a verified email", "Whether a verified email address for a user is required to forward to their identity provider.", BOOLEAN_TYPE, false, false); - private static final ProviderConfigProperty REQUIRE_VERIFIED_DOMAIN_PROPERTY = + private static final ProviderConfigProperty REQUIRE_VERIFIED_DOMAIN_PROPERTY = new ProviderConfigProperty( - HomeIdpDiscoveryConfig.REQUIRE_VERIFIED_DOMAIN, + REQUIRE_VERIFIED_DOMAIN, "Require a verified domain", "Whether a verified domain name for an organization is required to forward to their identity provider.", BOOLEAN_TYPE, false, false); - private static final ProviderConfigProperty USER_ATTRIBUTE_PROPERTY = - new ProviderConfigProperty( - HomeIdpDiscoveryConfig.USER_ATTRIBUTE, - "User attribute", - "The user attribute used to lookup the email address of the user.", - STRING_TYPE, - "email", - false); + private static final ProviderConfigProperty FORWARD_TO_LINKED_IDP_PROPERTY = new ProviderConfigProperty( + FORWARD_TO_LINKED_IDP, + "Forward to linked IdP", + "Whether to forward existing user to a linked identity provider or not.", + BOOLEAN_TYPE, + false, + false); + + private static final ProviderConfigProperty BYPASS_LOGIN_PAGE_PROPERTY = new ProviderConfigProperty( + BYPASS_LOGIN_PAGE, + "Bypass login page", + "If OIDC login_hint parameter is present, whether to bypass the login page for managed domains or not.", + BOOLEAN_TYPE, + false, + false); + + private static final ProviderConfigProperty FORWARD_TO_FIRST_MATCH_PROPERTY = new ProviderConfigProperty( + FORWARD_TO_FIRST_MATCH, + "Forward to first matched IdP", + "When multiple IdPs match the domain, whether to forward to the first IdP found or let the user choose.", + BOOLEAN_TYPE, + true, + false); + + private static final ProviderConfigProperty USER_ATTRIBUTE_PROPERTY = new ProviderConfigProperty( + USER_ATTRIBUTE, + "User attribute", + "The user attribute used to lookup the email address of the user.", + STRING_TYPE, + "email", + false); + + static final List CONFIG_PROPERTIES = ProviderConfigurationBuilder.create() + .property(USER_ATTRIBUTE_PROPERTY) + .property(REQUIRE_VERIFIED_EMAIL_PROPERTY) + .property(REQUIRE_VERIFIED_DOMAIN_PROPERTY) + .property(BYPASS_LOGIN_PAGE_PROPERTY) + .property(FORWARD_TO_LINKED_IDP_PROPERTY) + .property(FORWARD_TO_FIRST_MATCH_PROPERTY) + .build(); - static final List CONFIG_PROPERTIES = - ProviderConfigurationBuilder.create() - .property(USER_ATTRIBUTE_PROPERTY) - .property(REQUIRE_VERIFIED_EMAIL_PROPERTY) - .property(REQUIRE_VERIFIED_DOMAIN_PROPERTY) - .property(FORWARD_TO_LINKED_IDP_PROPERTY) - .build(); } diff --git a/src/main/java/io/phasetwo/service/auth/idp/IdentityProviderModelConfig.java b/src/main/java/io/phasetwo/service/auth/idp/IdentityProviderModelConfig.java index 21d1c10c..fef8c757 100755 --- a/src/main/java/io/phasetwo/service/auth/idp/IdentityProviderModelConfig.java +++ b/src/main/java/io/phasetwo/service/auth/idp/IdentityProviderModelConfig.java @@ -1,39 +1,58 @@ -// package de.sventorben.keycloak.authentication.hidpd; +//package de.sventorben.keycloak.authentication.hidpd; package io.phasetwo.service.auth.idp; -import java.util.Arrays; -import java.util.stream.Stream; import org.keycloak.models.Constants; import org.keycloak.models.IdentityProviderModel; +import java.util.Arrays; +import java.util.stream.Stream; + final class IdentityProviderModelConfig { - private static final String DOMAINS_ATTRIBUTE_KEY = "home.idp.discovery.domains"; + private static final String DOMAINS_ATTRIBUTE_KEY = "home.idp.discovery.domains"; + private static final String SUBDOMAINS_ATTRIBUTE_KEY = "home.idp.discovery.matchSubdomains"; - private final IdentityProviderModel identityProviderModel; + private final IdentityProviderModel identityProviderModel; - IdentityProviderModelConfig(IdentityProviderModel identityProviderModel) { - this.identityProviderModel = identityProviderModel; - } + IdentityProviderModelConfig(IdentityProviderModel identityProviderModel) { + this.identityProviderModel = identityProviderModel; + } - boolean hasDomain(String userAttributeName, String domain) { - return getDomains(userAttributeName).anyMatch(domain::equalsIgnoreCase); - } + boolean supportsDomain(String userAttributeName, Domain domain) { + boolean shouldMatchSubdomains = shouldMatchSubdomains(userAttributeName); + return getDomains(userAttributeName).anyMatch(it -> + it.equals(domain) || + (shouldMatchSubdomains && domain.isSubDomainOf(it))); + } - private Stream getDomains(String userAttributeName) { - String key = getDomainConfigKey(userAttributeName); - String domainsAttribute = identityProviderModel.getConfig().getOrDefault(key, ""); - return Arrays.stream(Constants.CFG_DELIMITER_PATTERN.split(domainsAttribute)); - } + private boolean shouldMatchSubdomains(String userAttributeName) { + String key = getSubdomainConfigKey(userAttributeName); + return Boolean.parseBoolean(identityProviderModel.getConfig().getOrDefault(key, "false")); + } - private String getDomainConfigKey(String userAttributeName) { - String key = DOMAINS_ATTRIBUTE_KEY; - if (userAttributeName != null) { - final String candidateKey = DOMAINS_ATTRIBUTE_KEY + "." + userAttributeName; - if (identityProviderModel.getConfig().containsKey(candidateKey)) { - key = candidateKey; - } + private Stream getDomains(String userAttributeName) { + String key = getDomainConfigKey(userAttributeName); + String domainsAttribute = identityProviderModel.getConfig().getOrDefault(key, ""); + return Arrays.stream(Constants.CFG_DELIMITER_PATTERN.split(domainsAttribute)).map(Domain::new); } - return key; - } + + private String getDomainConfigKey(String userAttributeName) { + return getConfigKey(DOMAINS_ATTRIBUTE_KEY, userAttributeName); + } + + private String getSubdomainConfigKey(String userAttributeName) { + return getConfigKey(SUBDOMAINS_ATTRIBUTE_KEY, userAttributeName); + } + + private String getConfigKey(String attributeKey, String userAttributeName) { + String key = attributeKey; + if (userAttributeName != null) { + final String candidateKey = attributeKey + "." + userAttributeName; + if (identityProviderModel.getConfig().containsKey(candidateKey)) { + key = candidateKey; + } + } + return key; + } + } diff --git a/src/main/java/io/phasetwo/service/auth/idp/IdpSelectorAuthenticator.java b/src/main/java/io/phasetwo/service/auth/idp/IdpSelectorAuthenticator.java index 8a31b8c5..c7ce99f1 100644 --- a/src/main/java/io/phasetwo/service/auth/idp/IdpSelectorAuthenticator.java +++ b/src/main/java/io/phasetwo/service/auth/idp/IdpSelectorAuthenticator.java @@ -78,7 +78,7 @@ private void redirect(AuthenticationFlowContext context, String providerId) { .getAuthenticationSession() .setAuthNote(AuthenticationProcessor.FORWARDED_PASSIVE_LOGIN, "true"); } - + log.debugf("Redirecting to %s", providerId); context.forceChallenge(response); */ diff --git a/src/main/java/io/phasetwo/service/auth/idp/LoginForm.java b/src/main/java/io/phasetwo/service/auth/idp/LoginForm.java new file mode 100755 index 00000000..7bee7a05 --- /dev/null +++ b/src/main/java/io/phasetwo/service/auth/idp/LoginForm.java @@ -0,0 +1,42 @@ +//package de.sventorben.keycloak.authentication.hidpd; +package io.phasetwo.service.auth.idp; + +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.forms.login.freemarker.model.IdentityProviderBean; +import org.keycloak.models.IdentityProviderModel; + +import java.net.URI; +import java.util.List; +import java.util.stream.Collectors; + +final class LoginForm { + + private final AuthenticationFlowContext context; + private final BaseUriLoginFormsProvider loginFormsProvider; + + LoginForm(AuthenticationFlowContext context, BaseUriLoginFormsProvider loginFormsProvider) { + this.context = context; + this.loginFormsProvider = loginFormsProvider; + } + + Response create(MultivaluedMap formData) { + LoginFormsProvider forms = context.form(); + if (!formData.isEmpty()) { + forms.setFormData(formData); + } + return forms.createLoginUsername(); + } + + Response create(List idps) { + URI baseUriWithCodeAndClientId = loginFormsProvider.getBaseUriWithCodeAndClientId(); + LoginFormsProvider forms = context.form(); + forms.setAttribute("hidpd", new IdentityProviderBean(context.getRealm(), + context.getSession(), + idps.stream().map(AlwaysSelectableIdentityProviderModel::new).collect(Collectors.toList()), + baseUriWithCodeAndClientId)); + return forms.createForm("hidpd-select-idp.ftl"); + } +} diff --git a/src/main/java/io/phasetwo/service/auth/idp/LoginHint.java b/src/main/java/io/phasetwo/service/auth/idp/LoginHint.java new file mode 100755 index 00000000..f0799f7b --- /dev/null +++ b/src/main/java/io/phasetwo/service/auth/idp/LoginHint.java @@ -0,0 +1,52 @@ +//package de.sventorben.keycloak.authentication.hidpd; +package io.phasetwo.service.auth.idp; + +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.models.FederatedIdentityModel; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.UserModel; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.services.managers.ClientSessionCode; +import org.keycloak.sessions.AuthenticationSessionModel; + +import java.util.Map; +import java.util.stream.Collectors; + +import static org.keycloak.protocol.oidc.OIDCLoginProtocol.LOGIN_HINT_PARAM; + +final class LoginHint { + + private final AuthenticationFlowContext context; + + LoginHint(AuthenticationFlowContext context) { + this.context = context; + } + + void setInAuthSession(IdentityProviderModel homeIdp, String defaultUsername) { + if (homeIdp == null) { + return; + } + String loginHint; + UserModel user = context.getUser(); + if (user != null) { + Map idpToUsername = context.getSession().users() + .getFederatedIdentitiesStream(context.getRealm(), user) + .collect( + Collectors.toMap(FederatedIdentityModel::getIdentityProvider, + FederatedIdentityModel::getUserName)); + loginHint = idpToUsername.getOrDefault(homeIdp.getAlias(), defaultUsername); + context.getAuthenticationSession().setClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM, loginHint); + } + } + + String getFromSession() { + return context.getAuthenticationSession().getClientNote(LOGIN_HINT_PARAM); + } + + void copyTo(ClientSessionCode clientSessionCode) { + String loginHint = getFromSession(); + if (clientSessionCode.getClientSession() != null && loginHint != null) { + clientSessionCode.getClientSession().setClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM, loginHint); + } + } +} diff --git a/src/main/java/io/phasetwo/service/auth/idp/LoginPage.java b/src/main/java/io/phasetwo/service/auth/idp/LoginPage.java new file mode 100755 index 00000000..a519d9e4 --- /dev/null +++ b/src/main/java/io/phasetwo/service/auth/idp/LoginPage.java @@ -0,0 +1,46 @@ +//package de.sventorben.keycloak.authentication.hidpd; +package io.phasetwo.service.auth.idp; + +import org.jboss.logging.Logger; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.util.TokenUtil; + +import java.util.Set; + +import static org.keycloak.protocol.oidc.OIDCLoginProtocol.*; +import static org.keycloak.protocol.saml.SamlProtocol.SAML_FORCEAUTHN_REQUIREMENT; +import static org.keycloak.protocol.saml.SamlProtocol.SAML_LOGIN_REQUEST_FORCEAUTHN; + +class LoginPage { + + private static final Logger LOG = Logger.getLogger(LoginPage.class); + private static final Set OIDC_PROMPT_NO_BYPASS = + Set.of(PROMPT_VALUE_LOGIN, PROMPT_VALUE_CONSENT, PROMPT_VALUE_SELECT_ACCOUNT); + + private final AuthenticationFlowContext context; + private final HomeIdpDiscoveryConfig config; + + LoginPage(AuthenticationFlowContext context, HomeIdpDiscoveryConfig config) { + this.context = context; + this.config = config; + } + + boolean shouldByPass() { + boolean bypassLoginPage = config.bypassLoginPage(); + if (bypassLoginPage) { + AuthenticationSessionModel authenticationSession = context.getAuthenticationSession(); + String prompt = authenticationSession.getClientNote(PROMPT_PARAM); + if (OIDC_PROMPT_NO_BYPASS.stream().anyMatch(it -> TokenUtil.hasPrompt(prompt, it))) { + LOG.debugf("OIDC: Forced by prompt=%s", prompt); + return false; + } + if (SAML_FORCEAUTHN_REQUIREMENT.equalsIgnoreCase( + authenticationSession.getAuthNote(SAML_LOGIN_REQUEST_FORCEAUTHN))) { + LOG.debugf("SAML: Forced authentication"); + return false; + } + } + return bypassLoginPage; + } +} diff --git a/src/main/java/io/phasetwo/service/auth/idp/Redirector.java b/src/main/java/io/phasetwo/service/auth/idp/Redirector.java index b1394ff8..d6415fc0 100755 --- a/src/main/java/io/phasetwo/service/auth/idp/Redirector.java +++ b/src/main/java/io/phasetwo/service/auth/idp/Redirector.java @@ -1,8 +1,6 @@ -// package de.sventorben.keycloak.authentication.hidpd; +//package de.sventorben.keycloak.authentication.hidpd; package io.phasetwo.service.auth.idp; -import static org.keycloak.services.resources.IdentityBrokerService.getIdentityProviderFactory; - import jakarta.ws.rs.core.Response; import org.jboss.logging.Logger; import org.keycloak.authentication.AuthenticationFlowContext; @@ -14,79 +12,61 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakUriInfo; import org.keycloak.models.RealmModel; -import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.services.Urls; import org.keycloak.services.managers.ClientSessionCode; import org.keycloak.sessions.AuthenticationSessionModel; -final class Redirector { +import static org.keycloak.services.resources.IdentityBrokerService.getIdentityProviderFactory; - private static final Logger LOG = Logger.getLogger(Redirector.class); +final class Redirector { - private final AuthenticationFlowContext context; + private static final Logger LOG = Logger.getLogger(Redirector.class); - Redirector(AuthenticationFlowContext context) { - this.context = context; - } + private final AuthenticationFlowContext context; - void redirectTo(IdentityProviderModel idp) { - String providerAlias = idp.getAlias(); - RealmModel realm = context.getRealm(); - AuthenticationSessionModel authenticationSession = context.getAuthenticationSession(); - KeycloakSession keycloakSession = context.getSession(); - ClientSessionCode clientSessionCode = - new ClientSessionCode<>(keycloakSession, realm, authenticationSession); - clientSessionCode.setAction(AuthenticationSessionModel.Action.AUTHENTICATE.name()); - if (idp.isLinkOnly()) { - LOG.warnf("Identity Provider %s is not allowed to perform a login.", providerAlias); - return; + Redirector(AuthenticationFlowContext context) { + this.context = context; } - String loginHint = - context.getAuthenticationSession().getClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM); - if (clientSessionCode.getClientSession() != null && loginHint != null) { - clientSessionCode - .getClientSession() - .setClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM, loginHint); + + void redirectTo(IdentityProviderModel idp) { + String providerAlias = idp.getAlias(); + RealmModel realm = context.getRealm(); + AuthenticationSessionModel authenticationSession = context.getAuthenticationSession(); + KeycloakSession keycloakSession = context.getSession(); + ClientSessionCode clientSessionCode = + new ClientSessionCode<>(keycloakSession, realm, authenticationSession); + clientSessionCode.setAction(AuthenticationSessionModel.Action.AUTHENTICATE.name()); + if (!idp.isEnabled()) { + LOG.warnf("Identity Provider %s is disabled.", providerAlias); + return; + } + if (idp.isLinkOnly()) { + LOG.warnf("Identity Provider %s is not allowed to perform a login.", providerAlias); + return; + } + new HomeIdpAuthenticationFlowContext(context).loginHint().copyTo(clientSessionCode); + IdentityProviderFactory providerFactory = getIdentityProviderFactory(keycloakSession, idp); + IdentityProvider identityProvider = providerFactory.create(keycloakSession, idp); + + Response response = identityProvider.performLogin(createAuthenticationRequest(providerAlias, clientSessionCode)); + context.forceChallenge(response); } - IdentityProviderFactory providerFactory = getIdentityProviderFactory(keycloakSession, idp); - IdentityProvider identityProvider = providerFactory.create(keycloakSession, idp); - Response response = - identityProvider.performLogin( - createAuthenticationRequest(providerAlias, clientSessionCode)); - context.forceChallenge(response); - } + private AuthenticationRequest createAuthenticationRequest(String providerId, ClientSessionCode clientSessionCode) { + AuthenticationSessionModel authSession = null; + IdentityBrokerState encodedState = null; - private AuthenticationRequest createAuthenticationRequest( - String providerId, ClientSessionCode clientSessionCode) { - AuthenticationSessionModel authSession = null; - IdentityBrokerState encodedState = null; + if (clientSessionCode != null) { + authSession = clientSessionCode.getClientSession(); + String relayState = clientSessionCode.getOrGenerateCode(); + encodedState = IdentityBrokerState.decoded(relayState, authSession.getClient().getId(), authSession.getClient().getClientId(), authSession.getTabId()); + } - if (clientSessionCode != null) { - authSession = clientSessionCode.getClientSession(); - String relayState = clientSessionCode.getOrGenerateCode(); - encodedState = - IdentityBrokerState.decoded( - relayState, - authSession.getClient().getId(), - authSession.getClient().getClientId(), - authSession.getTabId()); + KeycloakSession keycloakSession = context.getSession(); + KeycloakUriInfo keycloakUriInfo = keycloakSession.getContext().getUri(); + RealmModel realm = context.getRealm(); + String redirectUri = Urls.identityProviderAuthnResponse(keycloakUriInfo.getBaseUri(), providerId, realm.getName()).toString(); + return new AuthenticationRequest(keycloakSession, realm, authSession, context.getHttpRequest(), keycloakUriInfo, encodedState, redirectUri); } - KeycloakSession keycloakSession = context.getSession(); - KeycloakUriInfo keycloakUriInfo = keycloakSession.getContext().getUri(); - RealmModel realm = context.getRealm(); - String redirectUri = - Urls.identityProviderAuthnResponse( - keycloakUriInfo.getBaseUri(), providerId, realm.getName()) - .toString(); - return new AuthenticationRequest( - keycloakSession, - realm, - authSession, - context.getHttpRequest(), - keycloakUriInfo, - encodedState, - redirectUri); - } } diff --git a/src/main/java/io/phasetwo/service/auth/idp/RememberMe.java b/src/main/java/io/phasetwo/service/auth/idp/RememberMe.java new file mode 100644 index 00000000..c062a606 --- /dev/null +++ b/src/main/java/io/phasetwo/service/auth/idp/RememberMe.java @@ -0,0 +1,47 @@ +//package de.sventorben.keycloak.authentication.hidpd; +package io.phasetwo.service.auth.idp; + +import jakarta.ws.rs.core.MultivaluedMap; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.events.Details; +import org.keycloak.models.RealmModel; +import org.keycloak.services.managers.AuthenticationManager; + +final class RememberMe { + + private final AuthenticationFlowContext context; + + public RememberMe(AuthenticationFlowContext context) { + this.context = context; + } + + void remember(String username) { + String rememberMe = context.getAuthenticationSession().getAuthNote(Details.REMEMBER_ME); + RealmModel realm = context.getRealm(); + boolean remember = realm.isRememberMe() && "true".equalsIgnoreCase(rememberMe); + if (remember) { + AuthenticationManager.createRememberMeCookie(username, context.getUriInfo(), context.getSession()); + } else { + AuthenticationManager.expireRememberMeCookie(realm, context.getUriInfo(), context.getSession()); + } + } + + /* + * Sets session notes for interoperability with other authenticators and Keycloak defaults + */ + void handleAction(MultivaluedMap formData) { + boolean remember = context.getRealm().isRememberMe() && + "on".equalsIgnoreCase(formData.getFirst("rememberMe")); + if (remember) { + context.getAuthenticationSession().setAuthNote(Details.REMEMBER_ME, "true"); + context.getEvent().detail(Details.REMEMBER_ME, "true"); + } else { + context.getAuthenticationSession().removeAuthNote(Details.REMEMBER_ME); + } + } + + String getUserName() { + return AuthenticationManager.getRememberMeUsername(context.getRealm(), + context.getHttpRequest().getHttpHeaders()); + } +} diff --git a/src/main/java/io/phasetwo/service/auth/idp/package-info.java b/src/main/java/io/phasetwo/service/auth/idp/package-info.java index a5e668df..2aadcc21 100644 --- a/src/main/java/io/phasetwo/service/auth/idp/package-info.java +++ b/src/main/java/io/phasetwo/service/auth/idp/package-info.java @@ -7,6 +7,6 @@ *

Includes patches for loading from ATTEMPTED_USERNAME and looking up IdPs by an organization * domains table. * - *

Forked on October 4, 2022 from b4f8c78c26e7bfd072cf9b8467bc8bb6d129386d + *

Forked on September 4, 2023 from 04b9becfb37df63784559c936f0b49609686439e */ package io.phasetwo.service.auth.idp; diff --git a/src/main/resources/theme-resources/messages/messages_en.properties b/src/main/resources/theme-resources/messages/messages_en.properties index d0f64ef6..bcc84baa 100644 --- a/src/main/resources/theme-resources/messages/messages_en.properties +++ b/src/main/resources/theme-resources/messages/messages_en.properties @@ -8,6 +8,7 @@ doSelectIdp=SSO provider alias ext-auth-home-idp-discovery-display-name=Username SSO finder ext-auth-home-idp-discovery-help-text=Find your SSO provider by username +ext-auth-home-idp-discovery-identity-provider-login-label=Find your SSO provider ext-auth-idp-selector-display-name=SSO finder -ext-auth-idp-selector-help-text=Find your SSO provider by alias \ No newline at end of file +ext-auth-idp-selector-help-text=Find your SSO provider by alias diff --git a/src/main/resources/theme-resources/templates/hidpd-select-idp.ftl b/src/main/resources/theme-resources/templates/hidpd-select-idp.ftl new file mode 100644 index 00000000..8523f9cc --- /dev/null +++ b/src/main/resources/theme-resources/templates/hidpd-select-idp.ftl @@ -0,0 +1,28 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username') displayInfo=(realm.password && realm.registrationAllowed && !registrationDisabled??); section> + <#if section = "header"> + ${msg("loginAccountTitle")} + <#elseif section = "socialProviders" > + <#if realm.password && hidpd.providers??> +

+
+

${msg("ext-auth-home-idp-discovery-identity-provider-login-label")}

+ + +
+ + + +