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:
Lex Cao 2024-04-22 17:30:14 +08:00 committed by GitHub
parent 014b644724
commit 7e034dbbe0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 441 additions and 3 deletions

View file

@ -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.

View file

@ -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;

View file

@ -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,

View file

@ -82,6 +82,8 @@ public interface LoginFormsProvider extends Provider {
Response createIdpLinkConfirmLinkPage(); Response createIdpLinkConfirmLinkPage();
Response createIdpLinkConfirmOverrideLinkPage();
Response createIdpLinkEmailPage(); Response createIdpLinkEmailPage();
Response createLoginExpiredPage(); Response createLoginExpiredPage();

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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);

View file

@ -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:

View file

@ -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";

View file

@ -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();

View file

@ -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

View file

@ -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();
}
}

View file

@ -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)

View file

@ -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 " +

View file

@ -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;

View file

@ -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();

View file

@ -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");

View file

@ -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>

View file

@ -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.