Add IdpConfirmOverrideLinkAuthenticator to handle duplicate federated identity (#26393)
Closes #26201. Signed-off-by: Lex Cao <lexcao@foxmail.com> Co-authored-by: andymunro <48995441+andymunro@users.noreply.github.com>
This commit is contained in:
parent
014b644724
commit
7e034dbbe0
19 changed files with 441 additions and 3 deletions
|
@ -115,3 +115,19 @@ You could set the also set `Sync Mode` to `force` if you want to update the user
|
||||||
NOTE: This flow can be used if you want to delegate the identity to other identity providers (such as GitHub, Facebook ...) but you want to manage which users that can log in.
|
NOTE: This flow can be used if you want to delegate the identity to other identity providers (such as GitHub, Facebook ...) but you want to manage which users that can log in.
|
||||||
|
|
||||||
With this configuration, {project_name} is unable to determine which internal account corresponds to the external identity. The *Verify Existing Account By Re-authentication* authenticator asks the provider for the username and password.
|
With this configuration, {project_name} is unable to determine which internal account corresponds to the external identity. The *Verify Existing Account By Re-authentication* authenticator asks the provider for the username and password.
|
||||||
|
|
||||||
|
[[override_existing_broker_link]]
|
||||||
|
==== Override existing broker link
|
||||||
|
When an another account needs to be linked to the same {project_name} account within the same identity provider, you can configure the following authenticator.
|
||||||
|
|
||||||
|
Confirm Override Existing Link::
|
||||||
|
This authenticator will detect the existing broker link for the user and display a confirmation page to confirm overriding the existing broker link. Set the authenticator requirement to REQUIRED.
|
||||||
|
|
||||||
|
A typical use of this authenticator is a scenario such as the following:
|
||||||
|
* For example, consider a {project_name} user `john` with the email `john@gmail.com`. That user is linked to the identity provider `google` with the `google` username `john@gmail.com` .
|
||||||
|
* Then for instance {project_name} user `john` updates his email in {project_name} to `john-new@gmail.com`
|
||||||
|
* Then during login to {project_name}, the user authenticated to the identity provider `google` with a new username such as `john-new@gmail.com`, which is not linked to any {project_name} account yet (as {project_name} account `john` is still linked with the `google` user `john@gmail.com`) and hence the first-broker-login flow is triggered.
|
||||||
|
* During first-broker-login, the {project_name} user `john` is authenticated somehow (either by default first-broker-login re-authentication or for instance by authenticator like `Detect existing broker user`)
|
||||||
|
* Now with this authenticator in the authentication flow, it is possible to override the IDP link to the `google` identity provider of {project_name} user `john` with the new `google` link to `google` user `john-new@gmail.com` after user `john` confirms this.
|
||||||
|
|
||||||
|
When creating authentication flows with this authenticator, make sure to add this authenticator once other authenticators that are already established the {project_name} user by other means (either by re-authentication or after `Detect existing broker user` as mentioned above.
|
||||||
|
|
|
@ -164,7 +164,10 @@ public enum EventType implements EnumWithStableIndex {
|
||||||
USER_DISABLED_BY_TEMPORARY_LOCKOUT_ERROR(0x10000 + USER_DISABLED_BY_TEMPORARY_LOCKOUT.getStableIndex(), false),
|
USER_DISABLED_BY_TEMPORARY_LOCKOUT_ERROR(0x10000 + USER_DISABLED_BY_TEMPORARY_LOCKOUT.getStableIndex(), false),
|
||||||
|
|
||||||
OAUTH2_EXTENSION_GRANT(54, true),
|
OAUTH2_EXTENSION_GRANT(54, true),
|
||||||
OAUTH2_EXTENSION_GRANT_ERROR(0x10000 + OAUTH2_EXTENSION_GRANT.getStableIndex(), true);
|
OAUTH2_EXTENSION_GRANT_ERROR(0x10000 + OAUTH2_EXTENSION_GRANT.getStableIndex(), true),
|
||||||
|
|
||||||
|
FEDERATED_IDENTITY_OVERRIDE_LINK(55, true),
|
||||||
|
FEDERATED_IDENTITY_OVERRIDE_LINK_ERROR(0x10000 + FEDERATED_IDENTITY_OVERRIDE_LINK.getStableIndex(), true);
|
||||||
|
|
||||||
private final int stableIndex;
|
private final int stableIndex;
|
||||||
private final boolean saveByDefault;
|
private final boolean saveByDefault;
|
||||||
|
|
|
@ -23,7 +23,7 @@ package org.keycloak.forms.login;
|
||||||
public enum LoginFormsPages {
|
public enum LoginFormsPages {
|
||||||
|
|
||||||
LOGIN, LOGIN_USERNAME, LOGIN_PASSWORD, LOGIN_TOTP, LOGIN_CONFIG_TOTP, LOGIN_WEBAUTHN, LOGIN_VERIFY_EMAIL,
|
LOGIN, LOGIN_USERNAME, LOGIN_PASSWORD, LOGIN_TOTP, LOGIN_CONFIG_TOTP, LOGIN_WEBAUTHN, LOGIN_VERIFY_EMAIL,
|
||||||
LOGIN_IDP_LINK_CONFIRM, LOGIN_IDP_LINK_EMAIL,
|
LOGIN_IDP_LINK_CONFIRM, LOGIN_IDP_LINK_CONFIRM_OVERRIDE, LOGIN_IDP_LINK_EMAIL,
|
||||||
OAUTH_GRANT, LOGIN_RESET_PASSWORD, LOGIN_UPDATE_PASSWORD, LOGIN_SELECT_AUTHENTICATOR, REGISTER, INFO, ERROR, ERROR_WEBAUTHN, LOGIN_UPDATE_PROFILE,
|
OAUTH_GRANT, LOGIN_RESET_PASSWORD, LOGIN_UPDATE_PASSWORD, LOGIN_SELECT_AUTHENTICATOR, REGISTER, INFO, ERROR, ERROR_WEBAUTHN, LOGIN_UPDATE_PROFILE,
|
||||||
LOGIN_PAGE_EXPIRED, CODE, X509_CONFIRM, SAML_POST_FORM,
|
LOGIN_PAGE_EXPIRED, CODE, X509_CONFIRM, SAML_POST_FORM,
|
||||||
LOGIN_OAUTH2_DEVICE_VERIFY_USER_CODE, IDP_REVIEW_USER_PROFILE,
|
LOGIN_OAUTH2_DEVICE_VERIFY_USER_CODE, IDP_REVIEW_USER_PROFILE,
|
||||||
|
|
|
@ -82,6 +82,8 @@ public interface LoginFormsProvider extends Provider {
|
||||||
|
|
||||||
Response createIdpLinkConfirmLinkPage();
|
Response createIdpLinkConfirmLinkPage();
|
||||||
|
|
||||||
|
Response createIdpLinkConfirmOverrideLinkPage();
|
||||||
|
|
||||||
Response createIdpLinkEmailPage();
|
Response createIdpLinkEmailPage();
|
||||||
|
|
||||||
Response createLoginExpiredPage();
|
Response createLoginExpiredPage();
|
||||||
|
|
|
@ -0,0 +1,97 @@
|
||||||
|
/*
|
||||||
|
* 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.authentication.authenticators.broker;
|
||||||
|
|
||||||
|
import jakarta.ws.rs.core.MultivaluedMap;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import org.keycloak.authentication.AuthenticationFlowContext;
|
||||||
|
import org.keycloak.authentication.AuthenticationFlowError;
|
||||||
|
import org.keycloak.authentication.AuthenticationFlowException;
|
||||||
|
import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext;
|
||||||
|
import org.keycloak.broker.provider.BrokeredIdentityContext;
|
||||||
|
import org.keycloak.forms.login.LoginFormsProvider;
|
||||||
|
import org.keycloak.models.FederatedIdentityModel;
|
||||||
|
import org.keycloak.models.KeycloakSession;
|
||||||
|
import org.keycloak.models.RealmModel;
|
||||||
|
import org.keycloak.models.UserModel;
|
||||||
|
import org.keycloak.services.messages.Messages;
|
||||||
|
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||||
|
|
||||||
|
public class IdpConfirmOverrideLinkAuthenticator extends AbstractIdpAuthenticator {
|
||||||
|
|
||||||
|
public static final String OVERRIDE_LINK = "OVERRIDE_LINK";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void authenticateImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, BrokeredIdentityContext brokerContext) {
|
||||||
|
RealmModel realm = context.getRealm();
|
||||||
|
KeycloakSession session = context.getSession();
|
||||||
|
AuthenticationSessionModel authSession = context.getAuthenticationSession();
|
||||||
|
|
||||||
|
UserModel user = getExistingUser(session, realm, authSession);
|
||||||
|
|
||||||
|
String providerAlias = brokerContext.getIdpConfig().getAlias();
|
||||||
|
FederatedIdentityModel federatedIdentity = session.users()
|
||||||
|
.getFederatedIdentity(realm, user, providerAlias);
|
||||||
|
|
||||||
|
if (federatedIdentity == null) {
|
||||||
|
context.success();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String newBrokerUsername = brokerContext.getUsername();
|
||||||
|
String oldBrokerUsername = federatedIdentity.getUserName();
|
||||||
|
Response challenge = context.form()
|
||||||
|
.setStatus(Response.Status.OK)
|
||||||
|
.setAttribute(LoginFormsProvider.IDENTITY_PROVIDER_BROKER_CONTEXT, brokerContext)
|
||||||
|
.setError(
|
||||||
|
Messages.FEDERATED_IDENTITY_CONFIRM_OVERRIDE_MESSAGE,
|
||||||
|
user.getUsername(),
|
||||||
|
providerAlias,
|
||||||
|
newBrokerUsername,
|
||||||
|
providerAlias,
|
||||||
|
oldBrokerUsername
|
||||||
|
).createIdpLinkConfirmOverrideLinkPage();
|
||||||
|
|
||||||
|
context.challenge(challenge);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void actionImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, BrokeredIdentityContext brokerContext) {
|
||||||
|
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
|
||||||
|
|
||||||
|
String action = formData.getFirst("submitAction");
|
||||||
|
if (!"confirmOverride".equals(action)) {
|
||||||
|
throw new AuthenticationFlowException("Unknown action: " + action,
|
||||||
|
AuthenticationFlowError.INTERNAL_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthenticationSessionModel authSession = context.getAuthenticationSession();
|
||||||
|
authSession.setAuthNote(OVERRIDE_LINK, "true");
|
||||||
|
context.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean requiresUser() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,97 @@
|
||||||
|
/*
|
||||||
|
* 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.authentication.authenticators.broker;
|
||||||
|
|
||||||
|
import org.keycloak.Config;
|
||||||
|
import org.keycloak.authentication.Authenticator;
|
||||||
|
import org.keycloak.authentication.AuthenticatorFactory;
|
||||||
|
import org.keycloak.models.AuthenticationExecutionModel;
|
||||||
|
import org.keycloak.models.KeycloakSession;
|
||||||
|
import org.keycloak.models.KeycloakSessionFactory;
|
||||||
|
import org.keycloak.provider.ProviderConfigProperty;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class IdpConfirmOverrideLinkAuthenticatorFactory implements AuthenticatorFactory {
|
||||||
|
|
||||||
|
public static final String PROVIDER_ID = "idp-confirm-override-link";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Authenticator create(KeycloakSession session) {
|
||||||
|
return new IdpConfirmOverrideLinkAuthenticator();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getDisplayType() {
|
||||||
|
return "Confirm override existing link";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getReferenceCategory() {
|
||||||
|
return "confirmOverrideLink";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isConfigurable() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
|
||||||
|
return new AuthenticationExecutionModel.Requirement[]{
|
||||||
|
AuthenticationExecutionModel.Requirement.REQUIRED,
|
||||||
|
AuthenticationExecutionModel.Requirement.DISABLED
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isUserSetupAllowed() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getHelpText() {
|
||||||
|
return "Confirm override the link if there is an existing broker user linked to the account.";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ProviderConfigProperty> getConfigProperties() {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void init(Config.Scope config) {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void postInit(KeycloakSessionFactory factory) {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getId() {
|
||||||
|
return PROVIDER_ID;
|
||||||
|
}
|
||||||
|
}
|
|
@ -265,6 +265,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
|
||||||
attributes.put("email", emailBean);
|
attributes.put("email", emailBean);
|
||||||
break;
|
break;
|
||||||
case LOGIN_IDP_LINK_CONFIRM:
|
case LOGIN_IDP_LINK_CONFIRM:
|
||||||
|
case LOGIN_IDP_LINK_CONFIRM_OVERRIDE:
|
||||||
case LOGIN_IDP_LINK_EMAIL:
|
case LOGIN_IDP_LINK_EMAIL:
|
||||||
BrokeredIdentityContext brokerContext = (BrokeredIdentityContext) this.attributes.get(IDENTITY_PROVIDER_BROKER_CONTEXT);
|
BrokeredIdentityContext brokerContext = (BrokeredIdentityContext) this.attributes.get(IDENTITY_PROVIDER_BROKER_CONTEXT);
|
||||||
String idpAlias = brokerContext.getIdpConfig().getAlias();
|
String idpAlias = brokerContext.getIdpConfig().getAlias();
|
||||||
|
@ -656,6 +657,11 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
|
||||||
return createResponse(LoginFormsPages.LOGIN_IDP_LINK_CONFIRM);
|
return createResponse(LoginFormsPages.LOGIN_IDP_LINK_CONFIRM);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Response createIdpLinkConfirmOverrideLinkPage() {
|
||||||
|
return createResponse(LoginFormsPages.LOGIN_IDP_LINK_CONFIRM_OVERRIDE);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Response createLoginExpiredPage() {
|
public Response createLoginExpiredPage() {
|
||||||
return createResponse(LoginFormsPages.LOGIN_PAGE_EXPIRED);
|
return createResponse(LoginFormsPages.LOGIN_PAGE_EXPIRED);
|
||||||
|
|
|
@ -48,6 +48,8 @@ public class Templates {
|
||||||
return "login-verify-email.ftl";
|
return "login-verify-email.ftl";
|
||||||
case LOGIN_IDP_LINK_CONFIRM:
|
case LOGIN_IDP_LINK_CONFIRM:
|
||||||
return "login-idp-link-confirm.ftl";
|
return "login-idp-link-confirm.ftl";
|
||||||
|
case LOGIN_IDP_LINK_CONFIRM_OVERRIDE:
|
||||||
|
return "login-idp-link-confirm-override.ftl";
|
||||||
case LOGIN_IDP_LINK_EMAIL:
|
case LOGIN_IDP_LINK_EMAIL:
|
||||||
return "login-idp-link-email.ftl";
|
return "login-idp-link-email.ftl";
|
||||||
case OAUTH_GRANT:
|
case OAUTH_GRANT:
|
||||||
|
|
|
@ -89,6 +89,8 @@ public class Messages {
|
||||||
|
|
||||||
public static final String FEDERATED_IDENTITY_CONFIRM_LINK_MESSAGE = "federatedIdentityConfirmLinkMessage";
|
public static final String FEDERATED_IDENTITY_CONFIRM_LINK_MESSAGE = "federatedIdentityConfirmLinkMessage";
|
||||||
|
|
||||||
|
public static final String FEDERATED_IDENTITY_CONFIRM_OVERRIDE_MESSAGE = "federatedIdentityConfirmOverrideMessage";
|
||||||
|
|
||||||
public static final String FEDERATED_IDENTITY_CONFIRM_REAUTHENTICATE_MESSAGE = "federatedIdentityConfirmReauthenticateMessage";
|
public static final String FEDERATED_IDENTITY_CONFIRM_REAUTHENTICATE_MESSAGE = "federatedIdentityConfirmReauthenticateMessage";
|
||||||
|
|
||||||
public static final String NESTED_FIRST_BROKER_FLOW_MESSAGE = "nestedFirstBrokerFlowMessage";
|
public static final String NESTED_FIRST_BROKER_FLOW_MESSAGE = "nestedFirstBrokerFlowMessage";
|
||||||
|
|
|
@ -18,6 +18,7 @@ package org.keycloak.services.resources;
|
||||||
|
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
import org.jboss.resteasy.reactive.NoCache;
|
import org.jboss.resteasy.reactive.NoCache;
|
||||||
|
import org.keycloak.authentication.authenticators.broker.IdpConfirmOverrideLinkAuthenticator;
|
||||||
import org.keycloak.http.HttpRequest;
|
import org.keycloak.http.HttpRequest;
|
||||||
import org.keycloak.OAuthErrorException;
|
import org.keycloak.OAuthErrorException;
|
||||||
import org.keycloak.authentication.AuthenticationProcessor;
|
import org.keycloak.authentication.AuthenticationProcessor;
|
||||||
|
@ -711,7 +712,9 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add federated identity link here
|
// Add federated identity link here
|
||||||
if (! (federatedUser instanceof LightweightUserAdapter)) {
|
if (!(federatedUser instanceof LightweightUserAdapter)) {
|
||||||
|
checkOverrideLink(authSession, federatedUser, providerAlias);
|
||||||
|
|
||||||
FederatedIdentityModel federatedIdentityModel = new FederatedIdentityModel(context.getIdpConfig().getAlias(), context.getId(),
|
FederatedIdentityModel federatedIdentityModel = new FederatedIdentityModel(context.getIdpConfig().getAlias(), context.getId(),
|
||||||
context.getUsername(), context.getToken());
|
context.getUsername(), context.getToken());
|
||||||
session.users().addFederatedIdentity(realmModel, federatedUser, federatedIdentityModel);
|
session.users().addFederatedIdentity(realmModel, federatedUser, federatedIdentityModel);
|
||||||
|
@ -758,6 +761,25 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void checkOverrideLink(AuthenticationSessionModel authSession, UserModel federatedUser, String providerAlias) {
|
||||||
|
String isOverride = authSession.getAuthNote(IdpConfirmOverrideLinkAuthenticator.OVERRIDE_LINK);
|
||||||
|
if (!Boolean.parseBoolean(isOverride)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
FederatedIdentityModel previous = session.users()
|
||||||
|
.getFederatedIdentity(realmModel, federatedUser, providerAlias);
|
||||||
|
if (previous == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.users().removeFederatedIdentity(realmModel, federatedUser, providerAlias);
|
||||||
|
|
||||||
|
event.clone()
|
||||||
|
.event(EventType.FEDERATED_IDENTITY_OVERRIDE_LINK)
|
||||||
|
.detail(Details.PREF_PREVIOUS + Details.IDENTITY_PROVIDER_USERNAME, previous.getUserName())
|
||||||
|
.success();
|
||||||
|
}
|
||||||
|
|
||||||
private Response finishOrRedirectToPostBrokerLogin(AuthenticationSessionModel authSession, BrokeredIdentityContext context, boolean wasFirstBrokerLogin) {
|
private Response finishOrRedirectToPostBrokerLogin(AuthenticationSessionModel authSession, BrokeredIdentityContext context, boolean wasFirstBrokerLogin) {
|
||||||
String postBrokerLoginFlowId = context.getIdpConfig().getPostBrokerLoginFlowId();
|
String postBrokerLoginFlowId = context.getIdpConfig().getPostBrokerLoginFlowId();
|
||||||
|
|
|
@ -37,6 +37,7 @@ org.keycloak.authentication.authenticators.broker.IdpReviewProfileAuthenticatorF
|
||||||
org.keycloak.authentication.authenticators.broker.IdpCreateUserIfUniqueAuthenticatorFactory
|
org.keycloak.authentication.authenticators.broker.IdpCreateUserIfUniqueAuthenticatorFactory
|
||||||
org.keycloak.authentication.authenticators.broker.IdpDetectExistingBrokerUserAuthenticatorFactory
|
org.keycloak.authentication.authenticators.broker.IdpDetectExistingBrokerUserAuthenticatorFactory
|
||||||
org.keycloak.authentication.authenticators.broker.IdpConfirmLinkAuthenticatorFactory
|
org.keycloak.authentication.authenticators.broker.IdpConfirmLinkAuthenticatorFactory
|
||||||
|
org.keycloak.authentication.authenticators.broker.IdpConfirmOverrideLinkAuthenticatorFactory
|
||||||
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
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
/*
|
||||||
|
* 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.pages;
|
||||||
|
|
||||||
|
import org.openqa.selenium.WebElement;
|
||||||
|
import org.openqa.selenium.support.FindBy;
|
||||||
|
|
||||||
|
public class IdpConfirmOverrideLinkPage extends LanguageComboboxAwarePage {
|
||||||
|
|
||||||
|
@FindBy(id = "confirmOverride")
|
||||||
|
private WebElement confirmOverrideButton;
|
||||||
|
|
||||||
|
@FindBy(className = "alert-error")
|
||||||
|
private WebElement message;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isCurrent() {
|
||||||
|
return PageUtils.getPageTitle(driver).equals("Broker link already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMessage() {
|
||||||
|
return message.getText();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clickConfirmOverride() {
|
||||||
|
confirmOverrideButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void open() throws Exception {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
}
|
|
@ -205,6 +205,15 @@ public class AssertEvents implements TestRule {
|
||||||
.detail(Details.REDIRECT_URI, Matchers.equalTo(DEFAULT_REDIRECT_URI));
|
.detail(Details.REDIRECT_URI, Matchers.equalTo(DEFAULT_REDIRECT_URI));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ExpectedEvent expectIdentityProviderFirstLogin(RealmRepresentation realm, String identityProvider, String idpUsername) {
|
||||||
|
return expect(EventType.IDENTITY_PROVIDER_FIRST_LOGIN)
|
||||||
|
.client("broker-app")
|
||||||
|
.realm(realm)
|
||||||
|
.user((String)null)
|
||||||
|
.detail(Details.IDENTITY_PROVIDER, identityProvider)
|
||||||
|
.detail(Details.IDENTITY_PROVIDER_USERNAME, idpUsername);
|
||||||
|
}
|
||||||
|
|
||||||
public ExpectedEvent expectRegisterError(String username, String email) {
|
public ExpectedEvent expectRegisterError(String username, String email) {
|
||||||
UserRepresentation user = username != null ? getUser(username) : null;
|
UserRepresentation user = username != null ? getUser(username) : null;
|
||||||
return expect(EventType.REGISTER_ERROR)
|
return expect(EventType.REGISTER_ERROR)
|
||||||
|
|
|
@ -173,6 +173,7 @@ public class ProvidersTest extends AbstractAuthenticationTest {
|
||||||
addProviderInfo(result, "idp-auto-link", "Automatically set existing user", "Automatically set existing user to authentication context without any verification");
|
addProviderInfo(result, "idp-auto-link", "Automatically set existing user", "Automatically set existing user to authentication context without any verification");
|
||||||
addProviderInfo(result, "idp-confirm-link", "Confirm link existing account", "Show the form where user confirms if he wants " +
|
addProviderInfo(result, "idp-confirm-link", "Confirm link existing account", "Show the form where user confirms if he wants " +
|
||||||
"to link identity provider with existing account or rather edit user profile data retrieved from identity provider to avoid conflict");
|
"to link identity provider with existing account or rather edit user profile data retrieved from identity provider to avoid conflict");
|
||||||
|
addProviderInfo(result, "idp-confirm-override-link", "Confirm override existing link", "Confirm override the link if there is an existing broker user linked to the account.");
|
||||||
addProviderInfo(result, "idp-create-user-if-unique", "Create User If Unique", "Detect if there is existing Keycloak account " +
|
addProviderInfo(result, "idp-create-user-if-unique", "Create User If Unique", "Detect if there is existing Keycloak account " +
|
||||||
"with same email like identity provider. If no, create new user");
|
"with same email like identity provider. If no, create new user");
|
||||||
addProviderInfo(result, "idp-email-verification", "Verify existing account by Email", "Email verification of existing Keycloak " +
|
addProviderInfo(result, "idp-email-verification", "Verify existing account by Email", "Email verification of existing Keycloak " +
|
||||||
|
|
|
@ -34,6 +34,7 @@ import org.keycloak.testsuite.Assert;
|
||||||
import org.keycloak.testsuite.forms.VerifyProfileTest;
|
import org.keycloak.testsuite.forms.VerifyProfileTest;
|
||||||
import org.keycloak.testsuite.pages.ErrorPage;
|
import org.keycloak.testsuite.pages.ErrorPage;
|
||||||
import org.keycloak.testsuite.pages.IdpConfirmLinkPage;
|
import org.keycloak.testsuite.pages.IdpConfirmLinkPage;
|
||||||
|
import org.keycloak.testsuite.pages.IdpConfirmOverrideLinkPage;
|
||||||
import org.keycloak.testsuite.pages.IdpLinkEmailPage;
|
import org.keycloak.testsuite.pages.IdpLinkEmailPage;
|
||||||
import org.keycloak.testsuite.pages.InfoPage;
|
import org.keycloak.testsuite.pages.InfoPage;
|
||||||
import org.keycloak.testsuite.pages.LoginConfigTotpPage;
|
import org.keycloak.testsuite.pages.LoginConfigTotpPage;
|
||||||
|
@ -91,6 +92,9 @@ public abstract class AbstractBaseBrokerTest extends AbstractKeycloakTest {
|
||||||
@Page
|
@Page
|
||||||
protected IdpConfirmLinkPage idpConfirmLinkPage;
|
protected IdpConfirmLinkPage idpConfirmLinkPage;
|
||||||
|
|
||||||
|
@Page
|
||||||
|
protected IdpConfirmOverrideLinkPage idpConfirmOverrideLinkPage;
|
||||||
|
|
||||||
@Page
|
@Page
|
||||||
protected ProceedPage proceedPage;
|
protected ProceedPage proceedPage;
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,7 @@ import org.keycloak.models.IdentityProviderSyncMode;
|
||||||
import org.keycloak.models.UserModel;
|
import org.keycloak.models.UserModel;
|
||||||
import org.keycloak.representations.idm.ComponentRepresentation;
|
import org.keycloak.representations.idm.ComponentRepresentation;
|
||||||
import org.keycloak.representations.idm.EventRepresentation;
|
import org.keycloak.representations.idm.EventRepresentation;
|
||||||
|
import org.keycloak.representations.idm.FederatedIdentityRepresentation;
|
||||||
import org.keycloak.representations.idm.IdentityProviderMapperRepresentation;
|
import org.keycloak.representations.idm.IdentityProviderMapperRepresentation;
|
||||||
import org.keycloak.representations.idm.IdentityProviderRepresentation;
|
import org.keycloak.representations.idm.IdentityProviderRepresentation;
|
||||||
import org.keycloak.representations.idm.RealmRepresentation;
|
import org.keycloak.representations.idm.RealmRepresentation;
|
||||||
|
@ -30,10 +31,12 @@ import org.keycloak.representations.idm.UserRepresentation;
|
||||||
import org.keycloak.storage.UserStorageProvider;
|
import org.keycloak.storage.UserStorageProvider;
|
||||||
import org.keycloak.testsuite.Assert;
|
import org.keycloak.testsuite.Assert;
|
||||||
import org.keycloak.testsuite.AssertEvents;
|
import org.keycloak.testsuite.AssertEvents;
|
||||||
|
import org.keycloak.testsuite.admin.ApiUtil;
|
||||||
import org.keycloak.testsuite.federation.UserMapStorageFactory;
|
import org.keycloak.testsuite.federation.UserMapStorageFactory;
|
||||||
import org.keycloak.testsuite.forms.VerifyProfileTest;
|
import org.keycloak.testsuite.forms.VerifyProfileTest;
|
||||||
import org.keycloak.testsuite.pages.LoginPasswordUpdatePage;
|
import org.keycloak.testsuite.pages.LoginPasswordUpdatePage;
|
||||||
import org.keycloak.testsuite.util.AccountHelper;
|
import org.keycloak.testsuite.util.AccountHelper;
|
||||||
|
import org.keycloak.testsuite.util.FederatedIdentityBuilder;
|
||||||
import org.keycloak.testsuite.util.MailServer;
|
import org.keycloak.testsuite.util.MailServer;
|
||||||
import org.keycloak.testsuite.util.MailServerConfiguration;
|
import org.keycloak.testsuite.util.MailServerConfiguration;
|
||||||
import org.keycloak.testsuite.util.SecondBrowser;
|
import org.keycloak.testsuite.util.SecondBrowser;
|
||||||
|
@ -52,6 +55,7 @@ import static org.keycloak.storage.UserStorageProviderModel.IMPORT_ENABLED;
|
||||||
import static org.keycloak.testsuite.admin.ApiUtil.removeUserByUsername;
|
import static org.keycloak.testsuite.admin.ApiUtil.removeUserByUsername;
|
||||||
import static org.keycloak.testsuite.broker.BrokerRunOnServerUtil.assertHardCodedSessionNote;
|
import static org.keycloak.testsuite.broker.BrokerRunOnServerUtil.assertHardCodedSessionNote;
|
||||||
import static org.keycloak.testsuite.broker.BrokerRunOnServerUtil.configureAutoLinkFlow;
|
import static org.keycloak.testsuite.broker.BrokerRunOnServerUtil.configureAutoLinkFlow;
|
||||||
|
import static org.keycloak.testsuite.broker.BrokerRunOnServerUtil.configureConfirmOverrideLinkFlow;
|
||||||
import static org.keycloak.testsuite.broker.BrokerRunOnServerUtil.grantReadTokenRole;
|
import static org.keycloak.testsuite.broker.BrokerRunOnServerUtil.grantReadTokenRole;
|
||||||
import static org.keycloak.testsuite.broker.BrokerTestConstants.USER_EMAIL;
|
import static org.keycloak.testsuite.broker.BrokerTestConstants.USER_EMAIL;
|
||||||
import static org.keycloak.testsuite.broker.BrokerTestTools.getConsumerRoot;
|
import static org.keycloak.testsuite.broker.BrokerTestTools.getConsumerRoot;
|
||||||
|
@ -1458,6 +1462,73 @@ public abstract class AbstractFirstBrokerLoginTest extends AbstractInitializedBa
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testConfirmOverrideLink() {
|
||||||
|
RealmResource consumerRealm = adminClient.realm(bc.consumerRealmName());
|
||||||
|
RealmResource providerRealm = adminClient.realm(bc.providerRealmName());
|
||||||
|
|
||||||
|
testingClient.server(bc.consumerRealmName())
|
||||||
|
.run(configureConfirmOverrideLinkFlow(bc.getIDPAlias()));
|
||||||
|
|
||||||
|
// create a user with existing federated identity
|
||||||
|
String createdUser = createUser(bc.getUserLogin());
|
||||||
|
|
||||||
|
FederatedIdentityRepresentation identity = FederatedIdentityBuilder.create()
|
||||||
|
.userId("id")
|
||||||
|
.userName("username")
|
||||||
|
.identityProvider(bc.getIDPAlias())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (Response response = consumerRealm.users().get(createdUser)
|
||||||
|
.addFederatedIdentity(bc.getIDPAlias(), identity)) {
|
||||||
|
assertEquals("status", 204, response.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
// login with the same username user but different user id from provider
|
||||||
|
logInAsUserInIDP();
|
||||||
|
|
||||||
|
idpConfirmOverrideLinkPage.assertCurrent();
|
||||||
|
String expectMessage = "You are trying to link your account testuser with the " + bc.getIDPAlias() + " account testuser. " +
|
||||||
|
"But your account is already linked with different " + bc.getIDPAlias() + " account username. " +
|
||||||
|
"Can you confirm if you want to replace the existing link with the new account?";
|
||||||
|
assertEquals(expectMessage, idpConfirmOverrideLinkPage.getMessage());
|
||||||
|
idpConfirmOverrideLinkPage.clickConfirmOverride();
|
||||||
|
|
||||||
|
// assert federated identity override
|
||||||
|
UserRepresentation user = ApiUtil.findUserByUsername(providerRealm, bc.getUserLogin());
|
||||||
|
String providerUserId = user.getId();
|
||||||
|
List<FederatedIdentityRepresentation> federatedIdentities = consumerRealm.users().get(createdUser).getFederatedIdentity();
|
||||||
|
assertEquals(1, federatedIdentities.size());
|
||||||
|
FederatedIdentityRepresentation actual = federatedIdentities.get(0);
|
||||||
|
assertEquals(bc.getIDPAlias(), actual.getIdentityProvider());
|
||||||
|
assertEquals(bc.getUserLogin(), actual.getUserName());
|
||||||
|
if (this instanceof KcSamlFirstBrokerLoginTest) {
|
||||||
|
// for SAML, the userID is username
|
||||||
|
assertEquals(bc.getUserLogin(), actual.getUserId());
|
||||||
|
} else {
|
||||||
|
// for OIDC, the userID is id
|
||||||
|
assertEquals(providerUserId, actual.getUserId());
|
||||||
|
}
|
||||||
|
|
||||||
|
RealmRepresentation consumerRealmRep = consumerRealm.toRepresentation();
|
||||||
|
|
||||||
|
// one for showing the confirm page
|
||||||
|
events.expectIdentityProviderFirstLogin(consumerRealmRep, bc.getIDPAlias(), bc.getUserLogin())
|
||||||
|
.assertEvent(getFirstConsumerEvent());
|
||||||
|
// one for submitting the confirmAction
|
||||||
|
events.expectIdentityProviderFirstLogin(consumerRealmRep, bc.getIDPAlias(), bc.getUserLogin())
|
||||||
|
.assertEvent(getFirstConsumerEvent());
|
||||||
|
|
||||||
|
events.expect(EventType.FEDERATED_IDENTITY_OVERRIDE_LINK)
|
||||||
|
.client("broker-app")
|
||||||
|
.realm(consumerRealmRep)
|
||||||
|
.user(createdUser)
|
||||||
|
.detail(Details.IDENTITY_PROVIDER, bc.getIDPAlias())
|
||||||
|
.detail(Details.IDENTITY_PROVIDER_USERNAME, bc.getUserLogin())
|
||||||
|
.detail(Details.PREF_PREVIOUS + Details.IDENTITY_PROVIDER_USERNAME, "username")
|
||||||
|
.assertEvent(getFirstConsumerEvent());
|
||||||
|
}
|
||||||
|
|
||||||
private Runnable toggleRegistrationAllowed(String realmName, boolean registrationAllowed) {
|
private Runnable toggleRegistrationAllowed(String realmName, boolean registrationAllowed) {
|
||||||
RealmResource consumerRealm = adminClient.realm(realmName);
|
RealmResource consumerRealm = adminClient.realm(realmName);
|
||||||
RealmRepresentation realmRepresentation = consumerRealm.toRepresentation();
|
RealmRepresentation realmRepresentation = consumerRealm.toRepresentation();
|
||||||
|
|
|
@ -129,6 +129,48 @@ final class BrokerRunOnServerUtil {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static RunOnServer configureConfirmOverrideLinkFlow(String idpAlias) {
|
||||||
|
return session -> {
|
||||||
|
RealmModel appRealm = session.getContext().getRealm();
|
||||||
|
|
||||||
|
AuthenticationFlowModel newFlow = new AuthenticationFlowModel();
|
||||||
|
newFlow.setAlias("ConfirmOverrideLinkFlow");
|
||||||
|
newFlow.setDescription("ConfirmOverride");
|
||||||
|
newFlow.setProviderId("basic-flow");
|
||||||
|
newFlow.setBuiltIn(false);
|
||||||
|
newFlow.setTopLevel(true);
|
||||||
|
newFlow = appRealm.addAuthenticationFlow(newFlow);
|
||||||
|
|
||||||
|
AuthenticationExecutionModel execution = new AuthenticationExecutionModel();
|
||||||
|
execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED);
|
||||||
|
execution.setAuthenticatorFlow(false);
|
||||||
|
execution.setAuthenticator("idp-detect-existing-broker-user");
|
||||||
|
execution.setPriority(1);
|
||||||
|
execution.setParentFlow(newFlow.getId());
|
||||||
|
appRealm.addAuthenticatorExecution(execution);
|
||||||
|
|
||||||
|
execution = new AuthenticationExecutionModel();
|
||||||
|
execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED);
|
||||||
|
execution.setAuthenticatorFlow(false);
|
||||||
|
execution.setAuthenticator("idp-confirm-override-link");
|
||||||
|
execution.setPriority(2);
|
||||||
|
execution.setParentFlow(newFlow.getId());
|
||||||
|
appRealm.addAuthenticatorExecution(execution);
|
||||||
|
|
||||||
|
execution = new AuthenticationExecutionModel();
|
||||||
|
execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED);
|
||||||
|
execution.setAuthenticatorFlow(false);
|
||||||
|
execution.setAuthenticator("idp-auto-link");
|
||||||
|
execution.setPriority(3);
|
||||||
|
execution.setParentFlow(newFlow.getId());
|
||||||
|
appRealm.addAuthenticatorExecution(execution);
|
||||||
|
|
||||||
|
IdentityProviderModel idp = appRealm.getIdentityProviderByAlias(idpAlias);
|
||||||
|
idp.setFirstBrokerLoginFlowId(newFlow.getId());
|
||||||
|
appRealm.updateIdentityProvider(idp);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
static RunOnServer assertHardCodedSessionNote() {
|
static RunOnServer assertHardCodedSessionNote() {
|
||||||
return (session) -> {
|
return (session) -> {
|
||||||
RealmModel realm = session.realms().getRealmByName("consumer");
|
RealmModel realm = session.realms().getRealmByName("consumer");
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
<#import "template.ftl" as layout>
|
||||||
|
<@layout.registrationLayout; section>
|
||||||
|
<#if section = "header">
|
||||||
|
${msg("confirmOverrideIdpTitle")}
|
||||||
|
<#elseif section = "form">
|
||||||
|
<form id="kc-register-form" action="${url.loginAction}" method="post">
|
||||||
|
${msg("pageExpiredMsg1")} <a id="loginRestartLink" href="${url.loginRestartFlowUrl}">${msg("doClickHere")}</a>
|
||||||
|
|
||||||
|
<button type="submit" class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" name="submitAction" id="confirmOverride" value="confirmOverride">${msg("confirmOverrideIdpContinue", idpDisplayName)}</button>
|
||||||
|
</form>
|
||||||
|
</#if>
|
||||||
|
</@layout.registrationLayout>
|
|
@ -276,11 +276,14 @@ federatedIdentityUnavailableMessage=User {0} authenticated with identity provide
|
||||||
federatedIdentityUnmatchedEssentialClaimMessage=The ID token issued by the identity provider does not match the configured essential claim. Please contact your administrator.
|
federatedIdentityUnmatchedEssentialClaimMessage=The ID token issued by the identity provider does not match the configured essential claim. Please contact your administrator.
|
||||||
|
|
||||||
confirmLinkIdpTitle=Account already exists
|
confirmLinkIdpTitle=Account already exists
|
||||||
|
confirmOverrideIdpTitle=Broker link already exists
|
||||||
federatedIdentityConfirmLinkMessage=User with {0} {1} already exists. How do you want to continue?
|
federatedIdentityConfirmLinkMessage=User with {0} {1} already exists. How do you want to continue?
|
||||||
|
federatedIdentityConfirmOverrideMessage=You are trying to link your account {0} with the {1} account {2}. But your account is already linked with different {3} account {4}. Can you confirm if you want to replace the existing link with the new account?
|
||||||
federatedIdentityConfirmReauthenticateMessage=Authenticate to link your account with {0}
|
federatedIdentityConfirmReauthenticateMessage=Authenticate to link your account with {0}
|
||||||
nestedFirstBrokerFlowMessage=The {0} user {1} is not linked to any known user.
|
nestedFirstBrokerFlowMessage=The {0} user {1} is not linked to any known user.
|
||||||
confirmLinkIdpReviewProfile=Review profile
|
confirmLinkIdpReviewProfile=Review profile
|
||||||
confirmLinkIdpContinue=Add to existing account
|
confirmLinkIdpContinue=Add to existing account
|
||||||
|
confirmOverrideIdpContinue=Yes, override link with current account
|
||||||
|
|
||||||
configureTotpMessage=You need to set up Mobile Authenticator to activate your account.
|
configureTotpMessage=You need to set up Mobile Authenticator to activate your account.
|
||||||
configureBackupCodesMessage=You need to set up Backup Codes to activate your account.
|
configureBackupCodesMessage=You need to set up Backup Codes to activate your account.
|
||||||
|
|
Loading…
Reference in a new issue