From 025b2ba4421547cb65c8c727957fdba075c7f5df Mon Sep 17 00:00:00 2001 From: Marek Posolda Date: Thu, 17 Apr 2025 13:20:05 +0200 Subject: [PATCH] Introducing IdpLinkAction as AIA to replace client-initiated account linking (#38952) closes #37269 closes #35446 Signed-off-by: mposolda Co-authored-by: andymunro <48995441+andymunro@users.noreply.github.com> Signed-off-by: Marek Posolda --- .../release_notes/topics/26_3_0.adoc | 5 + .../identity-brokering/account-linking.adoc | 29 +- .../topics/changes/changes-26_3_0.adoc | 6 + .../src/account-security/AccountRow.tsx | 18 +- js/apps/account-ui/src/api/methods.ts | 25 - js/apps/account-ui/src/index.ts | 1 - .../migration/migrators/MigrateTo26_3_0.java | 53 ++ .../datastore/DefaultMigrationManager.java | 2 + .../authentication/RequiredActionContext.java | 6 + .../broker/provider/IdentityProvider.java | 2 +- .../models/utils/DefaultRequiredActions.java | 16 +- .../RequiredActionContextResult.java | 5 + .../oidc/AbstractOAuth2IdentityProvider.java | 4 +- .../broker/provider/IdpLinkAction.java | 229 +++++++++ .../keycloak/broker/saml/SAMLEndpoint.java | 4 +- .../managers/AuthenticationManager.java | 10 +- .../resources/IdentityBrokerService.java | 181 ++++--- .../resources/LoginActionsService.java | 11 +- .../resources/account/AccountConsole.java | 3 - .../account/LinkedAccountsResource.java | 32 +- .../java/org/keycloak/utils/BrokerUtil.java | 64 +++ ...cloak.authentication.RequiredActionFactory | 1 + .../authentication/RequiredActionsTest.java | 1 + .../testsuite/pages/IdpLinkActionPage.java | 56 ++ .../broker/KcOidcBrokerIdpLinkActionTest.java | 485 ++++++++++++++++++ .../migration/AbstractMigrationTest.java | 17 + .../JsonFileImport1903MigrationTest.java | 1 + .../JsonFileImport198MigrationTest.java | 1 + .../JsonFileImport255MigrationTest.java | 1 + .../JsonFileImport343MigrationTest.java | 1 + .../JsonFileImport483MigrationTest.java | 1 + .../JsonFileImport903MigrationTest.java | 1 + .../testsuite/migration/MigrationTest.java | 2 + .../theme/base/login/link-idp-action.ftl | 15 + .../login/messages/messages_en.properties | 3 + .../keycloak.v2/login/link-idp-action.ftl | 21 + 36 files changed, 1154 insertions(+), 159 deletions(-) create mode 100644 model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_3_0.java create mode 100644 services/src/main/java/org/keycloak/broker/provider/IdpLinkAction.java create mode 100644 services/src/main/java/org/keycloak/utils/BrokerUtil.java create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/IdpLinkActionPage.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerIdpLinkActionTest.java create mode 100644 themes/src/main/resources/theme/base/login/link-idp-action.ftl create mode 100644 themes/src/main/resources/theme/keycloak.v2/login/link-idp-action.ftl diff --git a/docs/documentation/release_notes/topics/26_3_0.adoc b/docs/documentation/release_notes/topics/26_3_0.adoc index 32eef7ed89d..575f799f13b 100644 --- a/docs/documentation/release_notes/topics/26_3_0.adoc +++ b/docs/documentation/release_notes/topics/26_3_0.adoc @@ -11,6 +11,11 @@ Asynchronous logging might be useful for deployments requiring **high throughput For more details, see the https://www.keycloak.org/server/logging[Logging guide]. += Client initiated linking of the user account to identity provider is based on AIA + +In this release, we added Application initiated action (AIA) implementation for linking a user account to the identity provider. The custom protocol, which was previously +used for client initiated account linking is deprecated now. For more information, see the link:{upgradingguide_link}[{upgradingguide_name}]. + = Deprecated for removal the Instagram Identity Broker It has been a while since discussions started about any activity around the Instagram Identity Broker diff --git a/docs/documentation/server_development/topics/identity-brokering/account-linking.adoc b/docs/documentation/server_development/topics/identity-brokering/account-linking.adoc index 2db64fb3702..02a62906680 100644 --- a/docs/documentation/server_development/topics/identity-brokering/account-linking.adoc +++ b/docs/documentation/server_development/topics/identity-brokering/account-linking.adoc @@ -1,4 +1,5 @@ +[_client-initiated-account-linking] === Client initiated account linking Some applications want to integrate with social providers like Facebook, but do not want to provide an option to login via @@ -13,9 +14,30 @@ back to the server. The server establishes the link and redirects back to the a There are some preconditions that must be met by the client application before it can initiate this protocol: * The desired identity provider must be configured and enabled for the user's realm in the admin console. -* The user account must already be logged in as an existing user via the OIDC protocol * The user must have an `account.manage-account` or `account.manage-account-links` role mapping. * The application must be granted the scope for those roles within its access token + +The protocol is realized by the link:{adminguide_link}#con-aia_server_administration_guide[Application-initiated action (AIA)]. If you want the user, who is authenticated in your client application, to link +to the identity provider, attach the parameter `kc_action` with the value `idp_link:` to the OIDC authentication URL and redirect the user to this URL. For example, +to request linking to the identity provider with the alias `my-oidc-provider`, attach the parameter such as: + +[source,subs="attributes+"] +---- +kc_action=idp_link:my-oidc-provider +---- + +==== Refreshing external tokens + +If you use the external token generated by logging into the provider (such as a Facebook or GitHub token), you can refresh this token by re-initiating the account linking API. + +==== Legacy client initiated account linking + +WARNING: The legacy client initiated account linking is using a custom protocol that is not based on AIA. If you are use this protocol, consider migrating +your client application to the AIA based protocol described above because legacy client initiated account linking might be removed in the future {project_name} versions. + +In addition to the preconditions above, the legacy client initiated account linking has another precondition: + +* The user account must already be logged in as an existing user via the OIDC protocol * The application must have access to its access token as it needs information within it to generate the redirect URL. To initiate the login, the application must fabricate a URL and redirect the user's browser to this URL. The URL looks like this: @@ -87,8 +109,3 @@ to the application. If there is an error condition and the auth server deems it [WARNING] While this API guarantees that the application initiated the request, it does not completely prevent CSRF attacks for this operation. The application is still responsible for guarding against CSRF attacks target at itself. - -==== Refreshing external tokens - -If you are using the external token generated by logging into the provider (i.e. a Facebook or GitHub token), you can refresh this token by re-initiating the account linking API. - diff --git a/docs/documentation/upgrading/topics/changes/changes-26_3_0.adoc b/docs/documentation/upgrading/topics/changes/changes-26_3_0.adoc index 2b280589865..30c2b2ace4b 100644 --- a/docs/documentation/upgrading/topics/changes/changes-26_3_0.adoc +++ b/docs/documentation/upgrading/topics/changes/changes-26_3_0.adoc @@ -18,6 +18,12 @@ It has been a while since discussions started about any activity around the Inst and any objection from the community about deprecating it for removal. For more details, see https://github.com/keycloak/keycloak/issues/37967[Deprecate for removal the Instagram social broker]. +=== Deprecated proprietary protocol for client initiated linking to the identity provider account + +When you want the user, who is authenticated to your client application, to link his or her account to a specific identity provider, consider using the Application initiated action (AIA) based +mechanism with the action `idp_link`. The proprietary custom protocol for client initiated account linking is deprecated now and might be removed in the future versions. For more information, see the +Client initiated account link section of the link:{developerguide_link}[{developerguide_name}]. + == Notable changes Notable changes where an internal behavior changed to prevent common misconfigurations, fix bugs or simplify running {project_name}. diff --git a/js/apps/account-ui/src/account-security/AccountRow.tsx b/js/apps/account-ui/src/account-security/AccountRow.tsx index a238e317951..21e0ffb726b 100644 --- a/js/apps/account-ui/src/account-security/AccountRow.tsx +++ b/js/apps/account-ui/src/account-security/AccountRow.tsx @@ -14,7 +14,7 @@ import { import { LinkIcon, UnlinkIcon } from "@patternfly/react-icons"; import { useTranslation } from "react-i18next"; -import { linkAccount, unLinkAccount } from "../api/methods"; +import { unLinkAccount } from "../api/methods"; import { LinkedAccountRepresentation } from "../api/representations"; import { useAccountAlerts } from "../utils/useAccountAlerts"; @@ -31,6 +31,7 @@ export const AccountRow = ({ }: AccountRowProps) => { const { t } = useTranslation(); const context = useEnvironment(); + const { login } = context.keycloak; const { addAlert, addError } = useAccountAlerts(); const unLink = async (account: LinkedAccountRepresentation) => { @@ -43,15 +44,6 @@ export const AccountRow = ({ } }; - const link = async (account: LinkedAccountRepresentation) => { - try { - const { accountLinkUri } = await linkAccount(context, account); - location.href = accountLinkUri; - } catch (error) { - addError("linkError", error); - } - }; - return ( link(account)} + onClick={() => { + login({ + action: "idp_link:" + account.providerAlias, + }); + }} > diff --git a/js/apps/account-ui/src/api/methods.ts b/js/apps/account-ui/src/api/methods.ts index 5d44ef87bb9..a47c00c8adf 100644 --- a/js/apps/account-ui/src/api/methods.ts +++ b/js/apps/account-ui/src/api/methods.ts @@ -4,7 +4,6 @@ import { } from "@keycloak/keycloak-ui-shared"; import OrganizationRepresentation from "@keycloak/keycloak-admin-client/lib/defs/organizationRepresentation"; -import { joinPath } from "../utils/joinPath"; import { parseResponse } from "./parse-response"; import { ClientRepresentation, @@ -143,30 +142,6 @@ export async function unLinkAccount( return parseResponse(response); } -export async function linkAccount( - context: KeycloakContext, - account: LinkedAccountRepresentation, -) { - const redirectUri = encodeURIComponent( - joinPath( - context.environment.serverBaseUrl, - "realms", - context.environment.realm, - "account", - "account-security", - "linked-accounts", - ), - ); - const response = await request( - "/linked-accounts/" + account.providerName, - context, - { - searchParams: { providerId: account.providerName, redirectUri }, - }, - ); - return parseResponse<{ accountLinkUri: string }>(response); -} - export async function getGroups({ signal, context }: CallOptions) { const response = await request("/groups", context, { signal, diff --git a/js/apps/account-ui/src/index.ts b/js/apps/account-ui/src/index.ts index adee82d589b..80d689570d8 100644 --- a/js/apps/account-ui/src/index.ts +++ b/js/apps/account-ui/src/index.ts @@ -50,7 +50,6 @@ export { getPermissionRequests, getPersonalInfo, getSupportedLocales, - linkAccount, savePersonalInfo, unLinkAccount, } from "./api/methods"; diff --git a/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_3_0.java b/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_3_0.java new file mode 100644 index 00000000000..5502b1a3828 --- /dev/null +++ b/model/storage-private/src/main/java/org/keycloak/migration/migrators/MigrateTo26_3_0.java @@ -0,0 +1,53 @@ +/* + * Copyright 2025 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.migration.migrators; + +import org.keycloak.migration.ModelVersion; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.utils.DefaultRequiredActions; +import org.keycloak.representations.idm.RealmRepresentation; + +/** + * @author Marek Posolda + */ +public class MigrateTo26_3_0 implements Migration { + + public static final ModelVersion VERSION = new ModelVersion("26.3.0"); + + @Override + public void migrate(KeycloakSession session) { + session.realms().getRealmsStream().forEach(realm -> migrateRealm(session, realm)); + } + + @Override + public void migrateImport(KeycloakSession session, RealmModel realm, RealmRepresentation rep, boolean skipUserDependent) { + migrateRealm(session, realm); + } + + @Override + public ModelVersion getVersion() { + return VERSION; + } + + private void migrateRealm(KeycloakSession session, RealmModel realm) { + DefaultRequiredActions.addIdpLink(realm); + } +} diff --git a/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultMigrationManager.java b/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultMigrationManager.java index 5792aa992fb..c2df3353f4d 100644 --- a/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultMigrationManager.java +++ b/model/storage-private/src/main/java/org/keycloak/storage/datastore/DefaultMigrationManager.java @@ -43,6 +43,7 @@ import org.keycloak.migration.migrators.MigrateTo25_0_0; import org.keycloak.migration.migrators.MigrateTo26_0_0; import org.keycloak.migration.migrators.MigrateTo26_1_0; import org.keycloak.migration.migrators.MigrateTo26_2_0; +import org.keycloak.migration.migrators.MigrateTo26_3_0; import org.keycloak.migration.migrators.MigrateTo2_0_0; import org.keycloak.migration.migrators.MigrateTo2_1_0; import org.keycloak.migration.migrators.MigrateTo2_2_0; @@ -125,6 +126,7 @@ public class DefaultMigrationManager implements MigrationManager { new MigrateTo26_0_0(), new MigrateTo26_1_0(), new MigrateTo26_2_0(), + new MigrateTo26_3_0(), }; private final KeycloakSession session; diff --git a/server-spi-private/src/main/java/org/keycloak/authentication/RequiredActionContext.java b/server-spi-private/src/main/java/org/keycloak/authentication/RequiredActionContext.java index 66baf9f8596..6d1f0296837 100755 --- a/server-spi-private/src/main/java/org/keycloak/authentication/RequiredActionContext.java +++ b/server-spi-private/src/main/java/org/keycloak/authentication/RequiredActionContext.java @@ -41,6 +41,7 @@ public interface RequiredActionContext { enum Status { CHALLENGE, SUCCESS, + CANCELLED, IGNORE, FAILURE } @@ -149,6 +150,11 @@ public interface RequiredActionContext { */ void success(); + /** + * Mark this action as cancelled. Can be only used in AIA + */ + void cancel(); + /** * Ignore this required action and go onto the next, or complete the flow. * diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/IdentityProvider.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/IdentityProvider.java index a9b462e4362..70af2af5508 100755 --- a/server-spi-private/src/main/java/org/keycloak/broker/provider/IdentityProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/IdentityProvider.java @@ -84,7 +84,7 @@ public interface IdentityProvider extends Provi * * @return see description */ - Response error(String message); + Response error(IdentityProviderModel idpConfig, String message); } C getConfig(); diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/DefaultRequiredActions.java b/server-spi-private/src/main/java/org/keycloak/models/utils/DefaultRequiredActions.java index 3a33f44aa00..4586e13b8c9 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/DefaultRequiredActions.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/DefaultRequiredActions.java @@ -83,7 +83,8 @@ public class DefaultRequiredActions { CONFIGURE_RECOVERY_AUTHN_CODES(UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name(), DefaultRequiredActions::addRecoveryAuthnCodesAction, () -> isFeatureEnabled(Profile.Feature.RECOVERY_CODES)), WEBAUTHN_REGISTER("webauthn-register", DefaultRequiredActions::addWebAuthnRegisterAction, () -> isFeatureEnabled(Profile.Feature.WEB_AUTHN)), WEBAUTHN_PASSWORDLESS_REGISTER("webauthn-register-passwordless", DefaultRequiredActions::addWebAuthnPasswordlessRegisterAction, () -> isFeatureEnabled(Profile.Feature.WEB_AUTHN)), - VERIFY_USER_PROFILE(UserModel.RequiredAction.VERIFY_PROFILE.name(), DefaultRequiredActions::addVerifyProfile); + VERIFY_USER_PROFILE(UserModel.RequiredAction.VERIFY_PROFILE.name(), DefaultRequiredActions::addVerifyProfile), + IDP_LINK_ACCOUNT("idp_link", DefaultRequiredActions::addIdpLink); private final String alias; private final Consumer addAction; @@ -222,6 +223,19 @@ public class DefaultRequiredActions { realm.addRequiredActionProvider(deleteCredential); } } + + public static void addIdpLink(RealmModel realm) { + if (realm.getRequiredActionProviderByAlias("idp_link") == null) { + RequiredActionProviderModel idpLink = new RequiredActionProviderModel(); + idpLink.setEnabled(true); + idpLink.setAlias("idp_link"); + idpLink.setName("Linking Identity Provider"); + idpLink.setProviderId("idp_link"); + idpLink.setDefaultAction(false); + idpLink.setPriority(110); + realm.addRequiredActionProvider(idpLink); + } + } public static void addUpdateLocaleAction(RealmModel realm) { if (realm.getRequiredActionProviderByAlias("update_user_locale") == null) { diff --git a/services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java b/services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java index 03c7517503f..0f4676ec816 100755 --- a/services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java +++ b/services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java @@ -145,6 +145,11 @@ public class RequiredActionContextResult implements RequiredActionContext { } + @Override + public void cancel() { + status = Status.CANCELLED; + } + @Override public void ignore() { status = Status.IGNORE; diff --git a/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java b/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java index eb8c390ce02..55b41c93166 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java @@ -526,11 +526,11 @@ public abstract class AbstractOAuth2IdentityProviderMarek Posolda + */ +public class IdpLinkAction implements RequiredActionProvider, RequiredActionFactory { + + protected static final Logger logger = Logger.getLogger(IdpLinkAction.class); + + public static final String PROVIDER_ID = "idp_link"; + + // Authentication session note indicating that client-initiated account linking was triggered from this action + public static final String KC_ACTION_LINKING_IDENTITY_PROVIDER = "kc_action_linking_identity_provider"; + + // Authentication session notes with the status of IDP linking and with the error from IDP linking (idp_link_error filled just in case that status is "error") + public static final String IDP_LINK_STATUS = "idp_link_status"; + public static final String IDP_LINK_ERROR = "idp_link_error"; + + @Override + public RequiredActionProvider create(KeycloakSession session) { + return this; + } + + @Override + public InitiatedActionSupport initiatedActionSupport() { + return InitiatedActionSupport.SUPPORTED; + } + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + + @Override + public void evaluateTriggers(RequiredActionContext context) { + + } + + @Override + public void requiredActionChallenge(RequiredActionContext context) { + AuthenticationSessionModel authSession = context.getAuthenticationSession(); + KeycloakSession session = context.getSession(); + RealmModel realm = context.getRealm(); + UserModel user = context.getUser(); + ClientModel client = authSession.getClient(); + EventBuilder event = context.getEvent().clone(); + event.event(EventType.FEDERATED_IDENTITY_LINK); + + String identityProviderAlias = authSession.getClientNote(Constants.KC_ACTION_PARAMETER); + if (identityProviderAlias == null) { + event.error(Errors.UNKNOWN_IDENTITY_PROVIDER); + context.ignore(); + return; + } + event.detail(Details.IDENTITY_PROVIDER, identityProviderAlias); + IdentityProviderModel identityProviderModel = session.identityProviders().getByAlias(identityProviderAlias); + if (identityProviderModel == null) { + event.error(Errors.UNKNOWN_IDENTITY_PROVIDER); + context.ignore(); + return; + } + + // Check role + ClientModel accountService = realm.getClientByClientId(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID); + RoleModel manageAccountRole = accountService.getRole(AccountRoles.MANAGE_ACCOUNT); + if (!user.hasRole(manageAccountRole) || !client.hasScope(manageAccountRole)) { + RoleModel linkRole = accountService.getRole(AccountRoles.MANAGE_ACCOUNT_LINKS); + if (!user.hasRole(linkRole) || !client.hasScope(linkRole)) { + event.error(Errors.NOT_ALLOWED); + context.ignore(); + return; + } + } + + String idpDisplayName = KeycloakModelUtils.getIdentityProviderDisplayName(session, identityProviderModel); + Response challenge = context.form() + .setAttribute("idpDisplayName", idpDisplayName) + .createForm("link-idp-action.ftl"); + context.challenge(challenge); + } + + @Override + public void processAction(RequiredActionContext context) { + AuthenticationSessionModel authSession = context.getAuthenticationSession(); + KeycloakSession session = context.getSession(); + RealmModel realm = context.getRealm(); + ClientModel client = authSession.getClient(); + + if (!Boolean.parseBoolean(authSession.getAuthNote(IdpLinkAction.KC_ACTION_LINKING_IDENTITY_PROVIDER))) { + // User confirmed IDP linking. We can redirect to IDP + String identityProviderAlias = authSession.getClientNote(Constants.KC_ACTION_PARAMETER); + + ClientSessionCode clientSessionCode = new ClientSessionCode<>(session, realm, authSession); + clientSessionCode.setAction(AuthenticationSessionModel.Action.AUTHENTICATE.name()); + String noteValue = authSession.getParentSession().getId() + client.getClientId() + identityProviderAlias; + authSession.setAuthNote(LINKING_IDENTITY_PROVIDER, noteValue); + authSession.setAuthNote(KC_ACTION_LINKING_IDENTITY_PROVIDER, "true"); + + IdentityBrokerService brokerService = new IdentityBrokerService(session); + Response response = brokerService.performClientInitiatedAccountLogin(identityProviderAlias, clientSessionCode); + context.challenge(response); + } else { + // User already authenticated with IDP + EventBuilder event = context.getEvent(); + event.event(EventType.FEDERATED_IDENTITY_LINK); + + // Status is supposed to be set by IdentityBrokerService + String statusNote = authSession.getAuthNote(IdpLinkAction.IDP_LINK_STATUS); + if (statusNote == null) { + removeAuthNotes(authSession); + logger.warn("Not found IDP_LINK_STATUS even if redirect to IDP was already triggered"); + context.failure(Errors.INVALID_REQUEST); + return; + } + RequiredActionContext.KcActionStatus status = RequiredActionContext.KcActionStatus.valueOf(statusNote); + switch (status) { + case SUCCESS: + context.success(); + break; + case CANCELLED: + context.cancel(); + break; + case ERROR: + String error = authSession.getAuthNote(IDP_LINK_ERROR); + errorPage(context, error); + break; + default: + throw new IllegalStateException("Unknown status in the note idp_link_status: " + status); + } + removeAuthNotes(authSession); + } + } + + private void removeAuthNotes(AuthenticationSessionModel authSession) { + authSession.removeAuthNote(IdpLinkAction.KC_ACTION_LINKING_IDENTITY_PROVIDER); + authSession.removeAuthNote(IdpLinkAction.IDP_LINK_STATUS); + authSession.removeAuthNote(IdpLinkAction.IDP_LINK_ERROR); + } + + private void errorPage(RequiredActionContext context, String serializedError) { + FormMessage formMessage; + try { + formMessage = JsonSerialization.readValue(serializedError, FormMessage.class); + } catch (IOException ioe) { + throw new RuntimeException("Unexpected error when deserialization of error: " + serializedError); + } + Response response = context.getSession().getProvider(LoginFormsProvider.class) + .setAuthenticationSession(context.getAuthenticationSession()) + .setUser(context.getUser()) + .setErrors(Collections.singletonList(formMessage)) + .createErrorPage(Response.Status.BAD_REQUEST); + context.getEvent().error(formMessage.getMessage()); + context.challenge(response); + } + + @Override + public String getDisplayText() { + return "Linking Identity Provider"; + } + + @Override + public void close() { + + } +} diff --git a/services/src/main/java/org/keycloak/broker/saml/SAMLEndpoint.java b/services/src/main/java/org/keycloak/broker/saml/SAMLEndpoint.java index 10b7660f4fb..b351e412658 100755 --- a/services/src/main/java/org/keycloak/broker/saml/SAMLEndpoint.java +++ b/services/src/main/java/org/keycloak/broker/saml/SAMLEndpoint.java @@ -542,11 +542,11 @@ public class SAMLEndpoint { if (Constants.AUTHENTICATION_EXPIRED_MESSAGE.equals(statusMessage)) { return callback.retryLogin(provider, authSession); } else { - return callback.error(statusMessage); + return callback.error(config, statusMessage); } } if (responseType.getAssertions() == null || responseType.getAssertions().isEmpty()) { - return callback.error(Messages.IDENTITY_PROVIDER_UNEXPECTED_ERROR); + return callback.error(config, Messages.IDENTITY_PROVIDER_UNEXPECTED_ERROR); } boolean assertionIsEncrypted = AssertionUtil.isAssertionEncrypted(responseType); diff --git a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java index e0d9960844d..8319ac8ec7f 100755 --- a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java +++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java @@ -1132,11 +1132,6 @@ public class AuthenticationManager { } } - public static Response actionRequired(final KeycloakSession session, final AuthenticationSessionModel authSession, - final HttpRequest request, final EventBuilder event) { - return actionRequired(session, authSession, request, event, new HashSet<>()); - } - private static Response actionRequired(final KeycloakSession session, final AuthenticationSessionModel authSession, final HttpRequest request, final EventBuilder event, Set ignoredActions) { final var realm = authSession.getRealm(); @@ -1460,6 +1455,11 @@ public class AuthenticationManager { throw new RuntimeException("Not allowed to call success() within evaluateTriggers()"); } + @Override + public void cancel() { + throw new RuntimeException("Not allowed to call cancel() within evaluateTriggers()"); + } + @Override public void ignore() { throw new RuntimeException("Not allowed to call ignore() within evaluateTriggers()"); diff --git a/services/src/main/java/org/keycloak/services/resources/IdentityBrokerService.java b/services/src/main/java/org/keycloak/services/resources/IdentityBrokerService.java index 92ba2e806a4..5ea9e11fe15 100755 --- a/services/src/main/java/org/keycloak/services/resources/IdentityBrokerService.java +++ b/services/src/main/java/org/keycloak/services/resources/IdentityBrokerService.java @@ -18,8 +18,10 @@ package org.keycloak.services.resources; import org.jboss.logging.Logger; import org.jboss.resteasy.reactive.NoCache; +import org.keycloak.authentication.RequiredActionContext; import org.keycloak.authentication.authenticators.broker.IdpConfirmOverrideLinkAuthenticator; import org.keycloak.broker.provider.ExchangeTokenToIdentityProviderToken; +import org.keycloak.broker.provider.IdpLinkAction; import org.keycloak.http.HttpRequest; import org.keycloak.OAuthErrorException; import org.keycloak.authentication.AuthenticationProcessor; @@ -86,7 +88,6 @@ import org.keycloak.services.managers.BruteForceProtector; import org.keycloak.services.managers.ClientSessionCode; import org.keycloak.services.messages.Messages; import org.keycloak.services.cors.Cors; -import org.keycloak.services.resources.account.AccountConsole; import org.keycloak.services.util.AuthenticationFlowURLHelper; import org.keycloak.services.util.BrowserHistoryHelper; import org.keycloak.services.util.CacheControlUtil; @@ -131,7 +132,7 @@ import java.util.stream.Stream; public class IdentityBrokerService implements IdentityProvider.AuthenticationCallback { // Authentication session note, which references identity provider that is currently linked - private static final String LINKING_IDENTITY_PROVIDER = "LINKING_IDENTITY_PROVIDER"; + public static final String LINKING_IDENTITY_PROVIDER = "LINKING_IDENTITY_PROVIDER"; private static final Logger logger = Logger.getLogger(IdentityBrokerService.class); @@ -208,12 +209,14 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal @GET @NoCache @Path("/{provider_alias}/link") + @Deprecated public Response clientInitiatedAccountLinking(@PathParam("provider_alias") String providerAlias, @QueryParam("redirect_uri") String redirectUri, @QueryParam("client_id") String clientId, @QueryParam("nonce") String nonce, @QueryParam("hash") String hash ) { + logger.warnf("Calling deprecated endpoint for client-initiated account linking. This endpoint will be removed in the future. Please use application initiated action (AIA) idp_link instead"); this.event.event(EventType.CLIENT_INITIATED_ACCOUNT_LINKING); checkRealm(); ClientModel client = checkClient(clientId); @@ -314,6 +317,7 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal } AuthenticationSessionModel authSession = rootAuthSession.createAuthenticationSession(client); + authSession.setAuthenticatedUser(userSession.getUser()); // Refresh the cookie new AuthenticationSessionManager(session).setAuthSessionCookie(userSession.getId()); @@ -329,6 +333,10 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal event.detail(Details.CODE_ID, userSession.getId()); event.success(); + return performClientInitiatedAccountLogin(providerAlias, clientSessionCode); + } + + public Response performClientInitiatedAccountLogin(String providerAlias, ClientSessionCode clientSessionCode) { try { IdentityProvider identityProvider = getIdentityProvider(session, providerAlias); Response response = identityProvider.performLogin(createAuthenticationRequest(identityProvider, providerAlias, clientSessionCode)); @@ -337,16 +345,16 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal if (isDebugEnabled()) { logger.debugf("Identity provider [%s] is going to send a request [%s].", identityProvider, response); } + return response; } } catch (IdentityBrokerException e) { - return redirectToErrorPage(authSession, Response.Status.INTERNAL_SERVER_ERROR, Messages.COULD_NOT_SEND_AUTHENTICATION_REQUEST, e, providerAlias); + return redirectToErrorPage(clientSessionCode.getClientSession(), Response.Status.INTERNAL_SERVER_ERROR, Messages.COULD_NOT_SEND_AUTHENTICATION_REQUEST, e, providerAlias); } catch (Exception e) { - return redirectToErrorPage(authSession, Response.Status.INTERNAL_SERVER_ERROR, Messages.UNEXPECTED_ERROR_HANDLING_REQUEST, e, providerAlias); + return redirectToErrorPage(clientSessionCode.getClientSession(), Response.Status.INTERNAL_SERVER_ERROR, Messages.UNEXPECTED_ERROR_HANDLING_REQUEST, e, providerAlias); } - return redirectToErrorPage(authSession, Response.Status.INTERNAL_SERVER_ERROR, Messages.COULD_NOT_PROCEED_WITH_AUTHENTICATION_REQUEST); - + return redirectToErrorPage(clientSessionCode.getClientSession(), Response.Status.INTERNAL_SERVER_ERROR, Messages.COULD_NOT_PROCEED_WITH_AUTHENTICATION_REQUEST); } @@ -566,10 +574,9 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal shouldMigrateId = true; } - // Check if federatedUser is already authenticated (this means linking social into existing federatedUser account) - UserSessionModel userSession = new AuthenticationSessionManager(session).getUserSession(authenticationSession); - if (shouldPerformAccountLinking(authenticationSession, userSession, providerAlias)) { - return performAccountLinking(authenticationSession, userSession, context, federatedIdentityModel, federatedUser); + // Check if linking was requested (for example by kc_action) or if we're authenticating + if (isDoingAccountLinking(authenticationSession, true, providerAlias)) { + return performAccountLinking(authenticationSession, context, federatedIdentityModel, federatedUser); } if (federatedUser == null) { @@ -906,23 +913,26 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal @Override public Response cancelled(IdentityProviderModel idpConfig) { AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession(); + event.detail(Details.IDENTITY_PROVIDER, idpConfig.getAlias()); - String idpDisplayName = KeycloakModelUtils.getIdentityProviderDisplayName(session, idpConfig); - Response accountManagementFailedLinking = checkAccountManagementFailedLinking(authSession, Messages.ACCESS_DENIED_WHEN_IDP_AUTH, idpDisplayName); - if (accountManagementFailedLinking != null) { - return accountManagementFailedLinking; + // Check if linking was requested (for example by kc_action) or if we're authenticating + if (isDoingAccountLinking(authSession, true, idpConfig.getAlias())) { + authSession.setAuthNote(IdpLinkAction.IDP_LINK_STATUS, RequiredActionContext.KcActionStatus.CANCELLED.name()); + return redirectAfterIDPLinking(authSession); } - + String idpDisplayName = KeycloakModelUtils.getIdentityProviderDisplayName(session, idpConfig); return browserAuthentication(authSession, Messages.ACCESS_DENIED_WHEN_IDP_AUTH, idpDisplayName); } @Override - public Response error(String message) { + public Response error(IdentityProviderModel idpConfig, String message) { AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession(); + event.detail(Details.IDENTITY_PROVIDER, idpConfig.getAlias()); - Response accountManagementFailedLinking = checkAccountManagementFailedLinking(authSession, message); - if (accountManagementFailedLinking != null) { - return accountManagementFailedLinking; + // Check if linking was requested (for example by kc_action) or if we're authenticating + UserSessionModel userSession = new AuthenticationSessionManager(session).getUserSession(authSession); + if (isDoingAccountLinking(authSession, true, idpConfig.getAlias())) { + return redirectToErrorWhenLinkingFailed(authSession, message); } Response passiveLoginErrorReturned = checkPassiveLoginError(authSession, message); @@ -934,18 +944,19 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal } - private boolean shouldPerformAccountLinking(AuthenticationSessionModel authSession, UserSessionModel userSession, String providerAlias) { + private boolean isDoingAccountLinking(AuthenticationSessionModel authSession, boolean checkProviderAlias, String providerAlias) { String noteFromSession = authSession.getAuthNote(LINKING_IDENTITY_PROVIDER); if (noteFromSession == null) { return false; } boolean linkingValid; - if (userSession == null) { - linkingValid = false; - } else { - String expectedNote = userSession.getId() + authSession.getClient().getClientId() + providerAlias; + if (checkProviderAlias) { + String expectedNote = authSession.getParentSession().getId() + authSession.getClient().getClientId() + providerAlias; linkingValid = expectedNote.equals(noteFromSession); + } else { + String expectedNotePrefix = authSession.getParentSession().getId() + authSession.getClient().getClientId(); + linkingValid = noteFromSession.startsWith(expectedNotePrefix); } if (linkingValid) { @@ -957,18 +968,18 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal } - private Response performAccountLinking(AuthenticationSessionModel authSession, UserSessionModel userSession, BrokeredIdentityContext context, FederatedIdentityModel newModel, UserModel federatedUser) { - logger.debugf("Will try to link identity provider [%s] to user [%s]", context.getIdpConfig().getAlias(), userSession.getUser().getUsername()); - + private Response performAccountLinking(AuthenticationSessionModel authSession, BrokeredIdentityContext context, FederatedIdentityModel newModel, UserModel federatedUser) { this.event.event(EventType.FEDERATED_IDENTITY_LINK); - - - UserModel authenticatedUser = userSession.getUser(); + UserModel authenticatedUser = authSession.getAuthenticatedUser(); authSession.setAuthenticatedUser(authenticatedUser); + logger.debugf("Will try to link identity provider [%s] to user [%s]", context.getIdpConfig().getAlias(), authenticatedUser.getUsername()); + if (federatedUser != null && !authenticatedUser.getId().equals(federatedUser.getId())) { - return redirectToErrorWhenLinkingFailed(authSession, Messages.IDENTITY_PROVIDER_ALREADY_LINKED, context.getIdpConfig().getAlias()); + logger.debugf("Cannot link user '%s' to identity provider '%s' . Other user '%s' already linked with the identity provider", authenticatedUser.getUsername(), context.getIdpConfig().getAlias(), federatedUser.getUsername()); + String idpDisplayName = KeycloakModelUtils.getIdentityProviderDisplayName(session, context.getIdpConfig()); + return redirectToErrorWhenLinkingFailed(authSession, Messages.IDENTITY_PROVIDER_ALREADY_LINKED, idpDisplayName); } if (!authenticatedUser.hasRole(this.realmModel.getClientByClientId(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID).getRole(AccountRoles.MANAGE_ACCOUNT))) { @@ -1000,37 +1011,77 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal context.getIdp().authenticationFinished(authSession, context); - AuthenticationManager.setClientScopesInSession(session, authSession); - TokenManager.attachAuthenticationSession(session, userSession, authSession); - if (isDebugEnabled()) { logger.debugf("Linking account [%s] from identity provider [%s] to user [%s].", newModel, context.getIdpConfig().getAlias(), authenticatedUser); } - this.event.user(authenticatedUser) - .detail(Details.USERNAME, authenticatedUser.getUsername()) - .detail(Details.IDENTITY_PROVIDER, newModel.getIdentityProvider()) - .detail(Details.IDENTITY_PROVIDER_USERNAME, newModel.getUserName()) - .success(); - // we do this to make sure that the parent IDP is logged out when this user session is complete. // But for the case when userSession was previously authenticated with broker1 and now is linked to another broker2, we shouldn't override broker1 notes with the broker2 for sure. // Maybe broker logout should be rather always skiped in case of broker-linking - if (userSession.getNote(Details.IDENTITY_PROVIDER) == null) { + UserSessionModel userSession = new AuthenticationSessionManager(session).getUserSession(authSession); + if (userSession != null && userSession.getNote(Details.IDENTITY_PROVIDER) == null) { userSession.setNote(Details.IDENTITY_PROVIDER, context.getIdpConfig().getAlias()); userSession.setNote(Details.IDENTITY_PROVIDER_USERNAME, context.getUsername()); } - return Response.status(302).location(UriBuilder.fromUri(authSession.getRedirectUri()).build()).build(); + authSession.setAuthNote(IdpLinkAction.IDP_LINK_STATUS, RequiredActionContext.KcActionStatus.SUCCESS.name()); + + if (!Boolean.parseBoolean(authSession.getAuthNote(IdpLinkAction.KC_ACTION_LINKING_IDENTITY_PROVIDER))) { + // Legacy client-initiated account linking + + // In legacy client-initiated account linking, the userSession should exists before linking was started, however it might be expired during the time when user is authenticating to the IDP + if (userSession == null) { + return redirectToErrorWhenLinkingFailed(authSession, Messages.BROKER_LINKING_SESSION_EXPIRED); + } + + AuthenticationManager.setClientScopesInSession(session, authSession); + TokenManager.attachAuthenticationSession(session, userSession, authSession); + + this.event.user(authenticatedUser) + .detail(Details.USERNAME, authenticatedUser.getUsername()) + .detail(Details.IDENTITY_PROVIDER, newModel.getIdentityProvider()) + .detail(Details.IDENTITY_PROVIDER_USERNAME, newModel.getUserName()) + .success(); + } + return redirectAfterIDPLinking(authSession); + } + + private Response redirectAfterIDPLinking(AuthenticationSessionModel authSession) { + URI redirect; + if (Boolean.parseBoolean(authSession.getAuthNote(IdpLinkAction.KC_ACTION_LINKING_IDENTITY_PROVIDER))) { + // Redirect to idp_link action to finish the flow properly + ClientSessionCode clientSessionCode = new ClientSessionCode<>(session, realmModel, authSession); + clientSessionCode.setAction(AuthenticationSessionModel.Action.REQUIRED_ACTIONS.name()); + String sessionCode = clientSessionCode.getOrGenerateCode(); + + authSession.setAction(AuthenticationSessionModel.Action.REQUIRED_ACTIONS.name()); + return new LoginActionsService(session, event).requiredActionPOST(null, + sessionCode, + authSession.getClientNote(Constants.KC_ACTION), + authSession.getClient().getClientId(), + AuthenticationProcessor.getClientData(session, authSession), + authSession.getTabId() + ); + } else { + // Legacy client-initiated account linking + redirect = UriBuilder.fromUri(authSession.getRedirectUri()).build(); + } + return Response.status(302).location(redirect).build(); } - private Response redirectToErrorWhenLinkingFailed(AuthenticationSessionModel authSession, String message, Object... parameters) { - if (authSession.getClient() != null && authSession.getClient().getClientId().equals(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID)) { - return redirectToAccountErrorPage(authSession, message, parameters); - } else { - return redirectToErrorPage(authSession, Response.Status.BAD_REQUEST, message, parameters); // Should rather redirect to app instead and display error here? + private Response redirectToErrorWhenLinkingFailed(AuthenticationSessionModel authSession, String error, Object... parameters) { + FormMessage errorMessage = new FormMessage(error, parameters); + String serializedError; + try { + serializedError = JsonSerialization.writeValueAsString(errorMessage); + } catch (IOException ioe) { + throw new RuntimeException(ioe); } + + authSession.setAuthNote(IdpLinkAction.IDP_LINK_STATUS, RequiredActionContext.KcActionStatus.ERROR.name()); + authSession.setAuthNote(IdpLinkAction.IDP_LINK_ERROR, serializedError); + return redirectAfterIDPLinking(authSession); } @@ -1150,9 +1201,9 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal AuthenticationSessionModel authSession = checks.getAuthenticationSession(); if (authSession != null) { - // Check if error happened during login or during linking from account management - Response accountManagementFailedLinking = checkAccountManagementFailedLinking(authSession, Messages.STALE_CODE_ACCOUNT); - if (accountManagementFailedLinking != null) { + // Check if error happened during login or during linking from some application like account console + if (isDoingAccountLinking(authSession, false, null)) { + Response accountManagementFailedLinking = redirectToErrorWhenLinkingFailed(authSession, Messages.STALE_CODE_ACCOUNT); throw new WebApplicationException(accountManagementFailedLinking); } else { Response errorResponse = checks.getResponse(); @@ -1173,21 +1224,6 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal } } - private Response checkAccountManagementFailedLinking(AuthenticationSessionModel authSession, String error, Object... parameters) { - UserSessionModel userSession = new AuthenticationSessionManager(session).getUserSession(authSession); - if (userSession != null && authSession.getClient() != null) { - - this.event.event(EventType.FEDERATED_IDENTITY_LINK); - UserModel user = userSession.getUser(); - this.event.user(user); - this.event.detail(Details.USERNAME, user.getUsername()); - - return redirectToAccountErrorPage(authSession, error, parameters); - } else { - return null; - } - } - /** * Checks if specified message matches one of the passive login error messages and if it does builds a response that * redirects the error back to the client. @@ -1256,21 +1292,6 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal throw new ErrorPageException(this.session, authSession, status, message, parameters); } - private Response redirectToAccountErrorPage(AuthenticationSessionModel authSession, String message, Object ... parameters) { - fireErrorEvent(message); - - FormMessage errorMessage = new FormMessage(message, parameters); - try { - String serializedError = JsonSerialization.writeValueAsString(errorMessage); - authSession.setAuthNote(AccountConsole.ACCOUNT_MGMT_FORWARDED_ERROR_NOTE, serializedError); - } catch (IOException ioe) { - throw new RuntimeException(ioe); - } - - URI accountServiceUri = UriBuilder.fromUri(authSession.getRedirectUri()).queryParam(Constants.TAB_ID, authSession.getTabId()).build(); - return Response.status(302).location(accountServiceUri).build(); - } - protected Response browserAuthentication(AuthenticationSessionModel authSession, String errorMessage, Object... parameters) { this.event.event(EventType.LOGIN); diff --git a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java index 62d051e2cdd..dc672593461 100755 --- a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java +++ b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java @@ -1174,7 +1174,7 @@ public class LoginActionsService { if (isCancelAppInitiatedAction(factory.getId(), authSession, context)) { provider.initiatedActionCanceled(session, authSession); AuthenticationManager.setKcActionStatus(factory.getId(), RequiredActionContext.KcActionStatus.CANCELLED, authSession); - context.success(); + context.cancel(); } else { provider.processAction(context); } @@ -1183,7 +1183,14 @@ public class LoginActionsService { authSession.setAuthNote(AuthenticationProcessor.LAST_PROCESSED_EXECUTION, action); } - if (context.getStatus() == RequiredActionContext.Status.SUCCESS) { + if (context.getStatus() == RequiredActionContext.Status.CANCELLED) { + event.clone().error(Errors.REJECTED_BY_USER); + initLoginEvent(authSession); + event.event(EventType.LOGIN); + authSession.removeAuthNote(AuthenticationProcessor.CURRENT_AUTHENTICATION_EXECUTION); + AuthenticationManager.setKcActionStatus(factory.getId(), RequiredActionContext.KcActionStatus.CANCELLED, authSession); + response = AuthenticationManager.nextActionAfterAuthentication(session, authSession, clientConnection, request, session.getContext().getUri(), event); + } else if (context.getStatus() == RequiredActionContext.Status.SUCCESS) { event.clone().success(); initLoginEvent(authSession); event.event(EventType.LOGIN); diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java b/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java index 66bf2e01bf7..bb5285b6d20 100644 --- a/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java @@ -69,9 +69,6 @@ import java.util.stream.Stream; */ public class AccountConsole implements AccountResourceProvider { - // Used when some other context (ie. IdentityBrokerService) wants to forward error to account management and display it here - public static final String ACCOUNT_MGMT_FORWARDED_ERROR_NOTE = "ACCOUNT_MGMT_FORWARDED_ERROR"; - private final Pattern bundleParamPattern = Pattern.compile("(\\{\\s*(\\d+)\\s*\\})"); protected final KeycloakSession session; diff --git a/services/src/main/java/org/keycloak/services/resources/account/LinkedAccountsResource.java b/services/src/main/java/org/keycloak/services/resources/account/LinkedAccountsResource.java index 126a6904f92..f43ff11c463 100644 --- a/services/src/main/java/org/keycloak/services/resources/account/LinkedAccountsResource.java +++ b/services/src/main/java/org/keycloak/services/resources/account/LinkedAccountsResource.java @@ -65,6 +65,7 @@ import org.keycloak.services.managers.Auth; import org.keycloak.services.messages.Messages; import org.keycloak.services.validation.Validation; import org.keycloak.theme.Theme; +import org.keycloak.utils.BrokerUtil; import org.keycloak.utils.StreamsUtil; import static org.keycloak.models.Constants.ACCOUNT_CONSOLE_CLIENT_ID; @@ -229,6 +230,12 @@ public class LinkedAccountsResource { .findFirst().orElse(null); } + /** + * Creating URL, which can be used to redirect to link identity provider with currently authenticated user + * + * @deprecated It is recommended to trigger linking identity provider account with the use of "idp_link" kc_action. + * @return response + */ @GET @Path("/{providerAlias}") @Produces(MediaType.APPLICATION_JSON) @@ -236,7 +243,10 @@ public class LinkedAccountsResource { public Response buildLinkedAccountURI(@PathParam("providerAlias") String providerAlias, @QueryParam("redirectUri") String redirectUri) { auth.require(AccountRoles.MANAGE_ACCOUNT); - + logger.warnf("Using deprecated endpoint of Account REST service for linking user '%s' in the realm '%s' to identity provider '%s'. It is recommended to use application initiated actions (AIA) for linking identity provider with the user.", + user.getUsername(), + realm.getName(), + providerAlias); if (redirectUri == null) { ErrorResponse.error(Messages.INVALID_REDIRECT_URI, Response.Status.BAD_REQUEST); } @@ -250,25 +260,7 @@ public class LinkedAccountsResource { } try { - String nonce = UUID.randomUUID().toString(); - MessageDigest md = MessageDigest.getInstance("SHA-256"); - String input = nonce + auth.getSession().getId() + ACCOUNT_CONSOLE_CLIENT_ID + providerAlias; - byte[] check = md.digest(input.getBytes(StandardCharsets.UTF_8)); - String hash = Base64Url.encode(check); - URI linkUri = Urls.identityProviderLinkRequest(this.session.getContext().getUri().getBaseUri(), providerAlias, realm.getName()); - linkUri = UriBuilder.fromUri(linkUri) - .queryParam("nonce", nonce) - .queryParam("hash", hash) - // need to use "account-console" client because IdentityBrokerService authenticates user using cookies - // the regular "account" client is used only for REST calls therefore cookies authentication cannot be used - .queryParam("client_id", ACCOUNT_CONSOLE_CLIENT_ID) - .queryParam("redirect_uri", redirectUri) - .build(); - - AccountLinkUriRepresentation rep = new AccountLinkUriRepresentation(); - rep.setAccountLinkUri(linkUri); - rep.setHash(hash); - rep.setNonce(nonce); + AccountLinkUriRepresentation rep = BrokerUtil.createClientInitiatedLinkURI(ACCOUNT_CONSOLE_CLIENT_ID, redirectUri, providerAlias, realm.getName(), auth.getSession().getId(), this.session.getContext().getUri().getBaseUri()); return Cors.builder().auth().allowedOrigins(auth.getToken()).add(Response.ok(rep)); } catch (Exception spe) { diff --git a/services/src/main/java/org/keycloak/utils/BrokerUtil.java b/services/src/main/java/org/keycloak/utils/BrokerUtil.java new file mode 100644 index 00000000000..05b947074d1 --- /dev/null +++ b/services/src/main/java/org/keycloak/utils/BrokerUtil.java @@ -0,0 +1,64 @@ +/* + * Copyright 2025 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.utils; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.UUID; + +import jakarta.ws.rs.core.UriBuilder; +import org.keycloak.common.util.Base64Url; +import org.keycloak.representations.account.AccountLinkUriRepresentation; +import org.keycloak.services.Urls; + +/** + * TODO: Remove this class once support for "client initiated account linking" is removed (Probably Keycloak 27) + * + * @author Marek Posolda + */ +public class BrokerUtil { + + public static AccountLinkUriRepresentation createClientInitiatedLinkURI(String clientId, String redirectUri, String identityProviderAlias, String realmName, String userSessionId, URI serverBaseUri) { + try { + String nonce = UUID.randomUUID().toString(); + MessageDigest md = MessageDigest.getInstance("SHA-256"); + String input = nonce + userSessionId + clientId + identityProviderAlias; + byte[] check = md.digest(input.getBytes(StandardCharsets.UTF_8)); + String hash = Base64Url.encode(check); + URI linkUri = Urls.identityProviderLinkRequest(serverBaseUri, identityProviderAlias, realmName); + linkUri = UriBuilder.fromUri(linkUri) + .queryParam("nonce", nonce) + .queryParam("hash", hash) + .queryParam("client_id", clientId) + .queryParam("redirect_uri", redirectUri) + .build(); + + AccountLinkUriRepresentation rep = new AccountLinkUriRepresentation(); + rep.setAccountLinkUri(linkUri); + rep.setHash(hash); + rep.setNonce(nonce); + return rep; + } catch (NoSuchAlgorithmException nsae) { + throw new RuntimeException(nsae); + } + } +} diff --git a/services/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory b/services/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory index fb61a458049..4d30a6a459c 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory @@ -28,3 +28,4 @@ org.keycloak.authentication.requiredactions.DeleteCredentialAction org.keycloak.authentication.requiredactions.VerifyUserProfile org.keycloak.authentication.requiredactions.RecoveryAuthnCodesAction org.keycloak.authentication.requiredactions.UpdateEmail +org.keycloak.broker.provider.IdpLinkAction diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/authentication/RequiredActionsTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/authentication/RequiredActionsTest.java index fc74b580f62..272c813384a 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/authentication/RequiredActionsTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/authentication/RequiredActionsTest.java @@ -71,6 +71,7 @@ public class RequiredActionsTest extends AbstractAuthenticationTest { addRequiredAction(expected, "VERIFY_PROFILE", "Verify Profile", true, false, null); addRequiredAction(expected, "delete_account", "Delete Account", false, false, null); addRequiredAction(expected, "delete_credential", "Delete Credential", true, false, null); + addRequiredAction(expected, "idp_link", "Linking Identity Provider", true, false, null); addRequiredAction(expected, "update_user_locale", "Update User Locale", true, false, null); addRequiredAction(expected, "webauthn-register", "Webauthn Register", true, false, null); addRequiredAction(expected, "webauthn-register-passwordless", "Webauthn Register Passwordless", true, false, null); diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/IdpLinkActionPage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/IdpLinkActionPage.java new file mode 100644 index 00000000000..fe7c33c1e3b --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/IdpLinkActionPage.java @@ -0,0 +1,56 @@ +/* + * Copyright 2025 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.junit.Assert; +import org.keycloak.testsuite.util.UIUtils; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; + +/** + * @author Marek Posolda + */ +public class IdpLinkActionPage extends AbstractPage { + + @FindBy(id = "kc-continue") + private WebElement submitButton; + + @FindBy(id = "kc-cancel") + private WebElement cancelButton; + + @FindBy(id = "kc-link-text") + private WebElement message; + + @Override + public boolean isCurrent() { + return PageUtils.getPageTitle(driver).startsWith("Linking "); + } + + public void confirm() { + UIUtils.clickLink(submitButton); + } + public void cancel() { + UIUtils.clickLink(cancelButton); + } + + public void assertIdpInMessage(String expectedIdpDisplayName) { + Assert.assertEquals("Do you want to link your account with " + expectedIdpDisplayName + "?", message.getText()); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerIdpLinkActionTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerIdpLinkActionTest.java new file mode 100644 index 00000000000..6d2a56eadc6 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerIdpLinkActionTest.java @@ -0,0 +1,485 @@ +/* + * Copyright 2025 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.broker; + +import java.net.URI; +import java.net.URL; +import java.util.Collections; +import java.util.List; + +import jakarta.ws.rs.core.Response; +import org.hamcrest.Matchers; +import org.jboss.arquillian.graphene.page.Page; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.authentication.RequiredActionContext; +import org.keycloak.broker.provider.IdpLinkAction; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.common.util.UriUtils; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.EventType; +import org.keycloak.models.Constants; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.RoleRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.services.messages.Messages; +import org.keycloak.testsuite.Assert; +import org.keycloak.testsuite.AssertEvents; +import org.keycloak.testsuite.pages.IdpLinkActionPage; +import org.keycloak.testsuite.util.AccountHelper; +import org.keycloak.testsuite.util.oauth.OAuthClient; +import org.keycloak.utils.BrokerUtil; + +import static org.hamcrest.Matchers.is; +import static org.keycloak.testsuite.broker.BrokerTestConstants.IDP_OIDC_ALIAS; + +/** + * Test for client-initiated-account linking of the custom application + * + * @author Marek Posolda + */ +public class KcOidcBrokerIdpLinkActionTest extends AbstractInitializedBaseBrokerTest { + + private static final BrokerConfiguration BROKER_CONFIG_INSTANCE = new KcOidcBrokerConfiguration() { + + @Override + public List createProviderClients() { + List providerClients = super.createProviderClients(); + providerClients.get(0).setConsentRequired(true); + return providerClients; + } + }; + + @Rule + public AssertEvents events = new AssertEvents(this); + + @Page + private IdpLinkActionPage idpLinkActionPage; + + @Override + protected BrokerConfiguration getBrokerConfiguration() { + return BROKER_CONFIG_INSTANCE; + } + + @Before + public void recreateConsumerUser() { + RealmResource providerRealmResource = realmsResouce().realm(bc.providerRealmName()); + + String consumerUserID1 = createUser(bc.consumerRealmName(), "user1", "password", "User1", "Last", "user1@keycloak.org", + user -> user.setEmailVerified(true)); + String consumerUserID2 = createUser(bc.consumerRealmName(), "user2", "password", "User2", "Last", "user2@keycloak.org", + user -> user.setEmailVerified(true)); + getCleanup(bc.consumerRealmName()).addUserId(consumerUserID1); + } + + // Test deprecated mechanism for client-initiated account linking + @Test + public void testAccountLinkingSuccess_legacyClientInitiatedAccountLinking() throws Exception { + String userSessionId = loginToConsumer(); + + // Redirect to link account on behalf of "broker-app" and login to the IDP + URI clientInitiatedAccountLinkUri = BrokerUtil.createClientInitiatedLinkURI("broker-app", oauth.getRedirectUri(), bc.getIDPAlias(), bc.consumerRealmName(), userSessionId, new URI(OAuthClient.AUTH_SERVER_ROOT)).getAccountLinkUri(); + driver.navigate().to(clientInitiatedAccountLinkUri.toString()); + loginPage.login(bc.getUserLogin(), bc.getUserPassword()); + + grantPage.assertCurrent(); + grantPage.accept(); + + appPage.assertCurrent(); + assertKcActionParams(null, null); + + // Check that user is linked to the IDP + assertUserLinkedToIDP(true); + } + + @Test + public void testAccountLinkingSuccess() throws Exception { + loginToConsumer(); + + // Redirect to link account on behalf of "broker-app" and login to the IDP + String kcAction = getKcActionParamForLinkIdp(bc.getIDPAlias()); + oauth.loginForm().kcAction(kcAction).open(); + confirmIdpLinking(); + + // Login to provider + loginPage.login(bc.getUserLogin(), bc.getUserPassword()); + + events.clear(); + grantPage.assertCurrent(); + grantPage.accept(); + + appPage.assertCurrent(); + assertKcActionParams(IdpLinkAction.PROVIDER_ID, RequiredActionContext.KcActionStatus.SUCCESS.name().toLowerCase()); + + // Check that user is linked to the IDP + assertUserLinkedToIDP(true); + + assertEvents((providerRealmId, providerUserId, consumerRealmId, consumerUserId, consumerUsername) -> { + assertProviderEventsSuccess(providerRealmId, providerUserId); + assertConsumerSuccessLinkEvents(consumerRealmId, consumerUserId, consumerUsername); + }); + } + + + @Test + public void testAccountLinkingSuccessTriggeredWhenUserNotAuthenticated() throws Exception { + // Check that user is not linked to the IDP + assertUserLinkedToIDP(false); + + // Login to consumer with "kc_action" when user not authenticated yet + String kcAction = getKcActionParamForLinkIdp(bc.getIDPAlias()); + oauth.client("broker-app"); + oauth.realm(bc.consumerRealmName()); + oauth.loginForm().kcAction(kcAction).open(); + loginPage.login("user1", "password"); + confirmIdpLinking(); + + // Login to provider + loginPage.login(bc.getUserLogin(), bc.getUserPassword()); + events.clear(); + grantPage.assertCurrent(); + grantPage.accept(); + + appPage.assertCurrent(); + assertKcActionParams(IdpLinkAction.PROVIDER_ID, RequiredActionContext.KcActionStatus.SUCCESS.name().toLowerCase()); + + // Check that user is linked to the IDP + assertUserLinkedToIDP(true); + + assertEvents((providerRealmId, providerUserId, consumerRealmId, consumerUserId, consumerUsername) -> { + assertProviderEventsSuccess(providerRealmId, providerUserId); + assertConsumerSuccessLinkEvents(consumerRealmId, consumerUserId, consumerUsername); + }); + } + + @Test + public void testAccountLinkingCancel() throws Exception { + loginToConsumer(); + + // Redirect to link account on behalf of "broker-app" and login to the IDP + String kcAction = getKcActionParamForLinkIdp(bc.getIDPAlias()); + oauth.loginForm().kcAction(kcAction).open(); + events.clear(); + + idpLinkActionPage.assertCurrent(); + idpLinkActionPage.assertIdpInMessage(bc.getIDPAlias()); + idpLinkActionPage.cancel(); + + appPage.assertCurrent(); + assertKcActionParams(IdpLinkAction.PROVIDER_ID, RequiredActionContext.KcActionStatus.CANCELLED.name().toLowerCase()); + + // Check that user is not linked to the IDP + assertUserLinkedToIDP(false); + + assertEvents((providerRealmId, providerUserId, consumerRealmId, consumerUserId, consumerUsername) -> { + events.expect(EventType.CUSTOM_REQUIRED_ACTION_ERROR) + .realm(consumerRealmId) + .client("broker-app") + .user(consumerUserId) + .detail(Details.CUSTOM_REQUIRED_ACTION, IdpLinkAction.PROVIDER_ID) + .detail(Details.USERNAME, consumerUsername) + .error(Errors.REJECTED_BY_USER) + .assertEvent(); + + events.expect(EventType.LOGIN) + .realm(consumerRealmId) + .client("broker-app") + .user(consumerUserId) + .session(Matchers.any(String.class)) + .detail(Details.USERNAME, consumerUsername) + .assertEvent(); + + events.assertEmpty(); + }); + } + + @Test + public void testAccountLinkingConsentRejectedAtIdp() throws Exception { + loginToConsumer(); + + // Redirect to link account on behalf of "broker-app" and login to the IDP + String kcAction = getKcActionParamForLinkIdp(bc.getIDPAlias()); + oauth.loginForm().kcAction(kcAction).open(); + confirmIdpLinking(); + + loginPage.login(bc.getUserLogin(), bc.getUserPassword()); + + events.clear(); + grantPage.assertCurrent(); + grantPage.cancel(); + + appPage.assertCurrent(); + assertKcActionParams(IdpLinkAction.PROVIDER_ID, RequiredActionContext.KcActionStatus.CANCELLED.name().toLowerCase()); + + // Check that user is not linked to the IDP + assertUserLinkedToIDP(false); + + assertEvents((providerRealmId, providerUserId, consumerRealmId, consumerUserId, consumerUsername) -> { + // Provider login - rejected consent screen + events.expect(EventType.LOGIN_ERROR) + .realm(providerRealmId) + .user(providerUserId) + .client(bc.getIDPClientIdInProviderRealm()) + .session((String)null) + .detail(Details.USERNAME, bc.getUserLogin()) + .error(Errors.REJECTED_BY_USER) + .assertEvent(); + + // Consumer - rejected provider consent screen event propagated + assertConsumerFailedLinkEvents(consumerRealmId, consumerUserId, consumerUsername, Errors.REJECTED_BY_USER, true); + + events.assertEmpty(); + }); + } + + + @Test + public void testAccountLinkingDifferentUserLinked() throws Exception { + // Link IDP to user "user2" + Response response = AccountHelper.addIdentityProvider(adminClient.realm(bc.consumerRealmName()), "user2", adminClient.realm(bc.providerRealmName()), bc.getUserLogin(), bc.getIDPAlias()); + Assert.assertEquals(204, response.getStatus()); + + // Linking the user "user1" to same IDP should fail + loginToConsumer(); + + String kcAction = getKcActionParamForLinkIdp(bc.getIDPAlias()); + oauth.loginForm().kcAction(kcAction).open(); + confirmIdpLinking(); + + loginPage.login(bc.getUserLogin(), bc.getUserPassword()); + + events.clear(); + grantPage.assertCurrent(); + grantPage.accept(); + + errorPage.assertCurrent(); + Assert.assertEquals("Federated identity returned by " + bc.getIDPAlias() + " is already linked to another user.", errorPage.getError()); + Assert.assertEquals(bc.createConsumerClients().get(0).getBaseUrl(), errorPage.getBackToApplicationLink()); + + // Check that user is not linked to the IDP + assertUserLinkedToIDP(false); + + assertEvents((providerRealmId, providerUserId, consumerRealmId, consumerUserId, consumerUsername) -> { + assertProviderEventsSuccess(providerRealmId, providerUserId); + assertConsumerFailedLinkEvents(consumerRealmId, consumerUserId, consumerUsername, Messages.IDENTITY_PROVIDER_ALREADY_LINKED, false); + + events.assertEmpty(); + }); + } + + + @Test + public void testAccountLinkingUserNotAllowed() throws Exception { + // Remove "manage-account" role from user + RealmResource consumerRealm = adminClient.realm(bc.consumerRealmName()); + String user1Id = consumerRealm.users().search("user1").iterator().next().getId(); + + RoleRepresentation defaultRoles = consumerRealm.roles().get(Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + bc.consumerRealmName()).toRepresentation(); + consumerRealm.users().get(user1Id).roles().realmLevel().remove(Collections.singletonList(defaultRoles)); + + // Linking the user "user1" to the IDP not allowed due insufficient permissions + loginToConsumer(); + + events.clear(); + + String kcAction = getKcActionParamForLinkIdp(bc.getIDPAlias()); + oauth.loginForm().kcAction(kcAction).open(); + + // Should be redirected to the application even before being redirected to IDP for authentication + appPage.assertCurrent(); + + // Check that user is not linked to the IDP + assertUserLinkedToIDP(false); + + assertEvents((providerRealmId, providerUserId, consumerRealmId, consumerUserId, consumerUsername) -> { + assertConsumerFailedLinkEvents(consumerRealmId, consumerUserId, consumerUsername, Errors.NOT_ALLOWED, true); + + events.assertEmpty(); + }); + + } + + @Test + public void testConsumerReauthentication() throws Exception { + loginToConsumer(); + + // Link IDP to user "user1" + Response response = AccountHelper.addIdentityProvider(adminClient.realm(bc.consumerRealmName()), "user1", adminClient.realm(bc.providerRealmName()), bc.getUserLogin(), bc.getIDPAlias()); + Assert.assertEquals(204, response.getStatus()); + + setTimeOffset(2); + + // Enforce re-authentication on "consumer" realm. Try to do re-authentication with the use of IDP, but reject consent screen on IDP side + oauth.loginForm().maxAge(1).open(); + loginPage.assertCurrent(bc.consumerRealmName()); + loginPage.clickSocial(bc.getIDPAlias()); + loginPage.login(bc.getUserLogin(), bc.getUserPassword()); + + events.clear(); + grantPage.assertCurrent(); + grantPage.cancel(); + + // Should be redirected back to "consumer" login + loginPage.assertCurrent(bc.consumerRealmName()); + Assert.assertEquals("Access denied when authenticating with kc-oidc-idp", loginPage.getError()); + + assertEvents((providerRealmId, providerUserId, consumerRealmId, consumerUserId, consumerUsername) -> { + // Provider login - rejected consent screen + events.expect(EventType.LOGIN_ERROR) + .realm(providerRealmId) + .user(providerUserId) + .client(bc.getIDPClientIdInProviderRealm()) + .session((String)null) + .detail(Details.USERNAME, bc.getUserLogin()) + .error(Errors.REJECTED_BY_USER) + .assertEvent(); + + events.assertEmpty(); + }); + } + + private String loginToConsumer() { + // Login to "consumer" realm with password + oauth.clientId("broker-app"); + loginPage.open(bc.consumerRealmName()); + loginPage.login("user1", "password"); + appPage.assertCurrent(); + String userSessionId = oauth.parseLoginResponse().getSessionState(); + + // Check that user is not linked to the IDP + assertUserLinkedToIDP(false); + + return userSessionId; + } + + private void confirmIdpLinking() { + idpLinkActionPage.assertCurrent(); + idpLinkActionPage.assertIdpInMessage(bc.getIDPAlias()); + idpLinkActionPage.confirm(); + } + + private static String getKcActionParamForLinkIdp(String providerAlias) { + return IdpLinkAction.PROVIDER_ID + ":" + providerAlias; + } + + private void assertKcActionParams(String expectedKcAction, String expectedKcActionStatus) throws Exception { + MultivaluedHashMap params = UriUtils.decodeQueryString(new URL(driver.getCurrentUrl()).getQuery()); + Assert.assertEquals(expectedKcAction, params.getFirst(Constants.KC_ACTION)); + Assert.assertEquals(expectedKcActionStatus, params.getFirst(Constants.KC_ACTION_STATUS)); + } + + private void assertUserLinkedToIDP(boolean expectedLinked) { + Assert.assertThat(expectedLinked, is(AccountHelper.isIdentityProviderLinked(adminClient.realm(bc.consumerRealmName()), "user1", bc.getIDPAlias()))); + } + + @FunctionalInterface + public interface EventDataConsumer { + void accept(String providerRealmId, String providerUserId, String consumerRealmId, String consumerUserId, String consumerUsername); + } + + private void assertEvents(EventDataConsumer assertImpl) { + RealmResource providerRealm = adminClient.realm(bc.providerRealmName()); + String providerRealmId = providerRealm.toRepresentation().getId(); + UserRepresentation providerUser = providerRealm.users().search(bc.getUserLogin()).iterator().next(); + String providerUserId = providerUser.getId(); + + String username = "user1"; + RealmResource consumerRealm = adminClient.realm(bc.consumerRealmName()); + String consumerRealmId = consumerRealm.toRepresentation().getId(); + UserRepresentation consumerUser = consumerRealm.users().search(username).iterator().next(); + String consumerUserId = consumerUser.getId(); + + assertImpl.accept(providerRealmId, providerUserId, consumerRealmId, consumerUserId, username); + } + + private void assertProviderEventsSuccess(String providerRealmId, String providerUserId) { + events.expect(EventType.LOGIN) + .realm(providerRealmId) + .user(providerUserId) + .client(bc.getIDPClientIdInProviderRealm()) + .session(Matchers.any(String.class)) + .detail(Details.USERNAME, bc.getUserLogin()) + .assertEvent(); + + events.expect(EventType.CODE_TO_TOKEN) + .session(Matchers.any(String.class)) + .realm(providerRealmId) + .user(providerUserId) + .client(bc.getIDPClientIdInProviderRealm()) + .assertEvent(); + + events.expect(EventType.USER_INFO_REQUEST) + .session(Matchers.any(String.class)) + .realm(providerRealmId) + .user(providerUserId) + .client(bc.getIDPClientIdInProviderRealm()) + .assertEvent(); + } + + private void assertConsumerSuccessLinkEvents(String consumerRealmId, String consumerUserId, String username) { + events.expect(EventType.FEDERATED_IDENTITY_LINK) + .realm(consumerRealmId) + .client("broker-app") + .user(consumerUserId) + .detail(Details.USERNAME, username) + .detail(Details.IDENTITY_PROVIDER, IDP_OIDC_ALIAS) + .detail(Details.IDENTITY_PROVIDER_USERNAME, bc.getUserLogin()) + .detail(Details.IDENTITY_PROVIDER_BROKER_SESSION_ID, Matchers.startsWith(bc.getIDPAlias())) + .assertEvent(); + + events.expect(EventType.LOGIN) + .realm(consumerRealmId) + .client("broker-app") + .user(consumerUserId) + .session(Matchers.any(String.class)) + .detail(Details.USERNAME, username) + .assertEvent(); + + events.assertEmpty(); + } + + private void assertConsumerFailedLinkEvents(String consumerRealmId, String consumerUserId, String consumerUsername, String expectedError, boolean expectLoginEvent) { + events.expect(EventType.FEDERATED_IDENTITY_LINK_ERROR) + .realm(consumerRealmId) + .client("broker-app") + .user(consumerUserId) + .detail(Details.USERNAME, consumerUsername) + .detail(Details.IDENTITY_PROVIDER, IDP_OIDC_ALIAS) + .error(expectedError) + .assertEvent(); + + if (expectLoginEvent) { + events.expect(EventType.LOGIN) + .realm(consumerRealmId) + .client("broker-app") + .user(consumerUserId) + .session(Matchers.any(String.class)) + .detail(Details.USERNAME, consumerUsername) + .assertEvent(); + } + + events.assertEmpty(); + } + + +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/AbstractMigrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/AbstractMigrationTest.java index 551192694e4..1005680325f 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/AbstractMigrationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/AbstractMigrationTest.java @@ -443,6 +443,10 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest { testRealmDefaultClientScopes(migrationRealm); } + protected void testMigrationTo26_3_0() { + testIdpLinkActionAvailable(migrationRealm); + } + private void testClientContainsExpectedClientScopes() { // Test OIDC client contains expected client scopes ClientResource migrationTestOIDCClient = ApiUtil.findClientByClientId(migrationRealm, "migration-test-client"); @@ -995,6 +999,8 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest { assertEquals(1000, action.getPriority()); } else if (action.getAlias().equals("delete_credential")) { assertEquals(100, action.getPriority()); + } else if (action.getAlias().equals("idp_link")) { + assertEquals(110, action.getPriority()); } else { assertEquals(priority, action.getPriority()); } @@ -1349,6 +1355,17 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest { assertFalse(rep.isDefaultAction()); } + private void testIdpLinkActionAvailable(RealmResource realm) { + RequiredActionProviderRepresentation rep = realm.flows().getRequiredAction("idp_link"); + assertNotNull(rep); + assertEquals("idp_link", rep.getAlias()); + assertEquals("idp_link", rep.getProviderId()); + assertEquals("Linking Identity Provider", rep.getName()); + assertEquals(110, rep.getPriority()); + assertTrue(rep.isEnabled()); + assertFalse(rep.isDefaultAction()); + } + private void testIdentityProviderConfigMigration(final RealmResource realm) { IdentityProviderRepresentation rep = realm.identityProviders().get("gitlab").toRepresentation(); // gitlab identity provider should have it's hideOnLoginPage attribute migrated from the config to the provider itself. diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport1903MigrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport1903MigrationTest.java index 24093641896..4b8e30c7c14 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport1903MigrationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport1903MigrationTest.java @@ -70,6 +70,7 @@ public class JsonFileImport1903MigrationTest extends AbstractJsonFileImportMigra testMigrationTo24_x(true, true); testMigrationTo25_0_0(); testMigrationTo26_0_0(true); + testMigrationTo26_3_0(); } @Test diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport198MigrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport198MigrationTest.java index 9dbec7ff8f9..7c6c932dab4 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport198MigrationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport198MigrationTest.java @@ -81,6 +81,7 @@ public class JsonFileImport198MigrationTest extends AbstractJsonFileImportMigrat testMigrationTo24_x(false); testMigrationTo25_0_0(); testMigrationTo26_0_0(false); + testMigrationTo26_3_0(); } @Override diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport255MigrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport255MigrationTest.java index c484b741245..47c7cde4bbc 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport255MigrationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport255MigrationTest.java @@ -75,6 +75,7 @@ public class JsonFileImport255MigrationTest extends AbstractJsonFileImportMigrat testMigrationTo24_x(false); testMigrationTo25_0_0(); testMigrationTo26_0_0(false); + testMigrationTo26_3_0(); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport343MigrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport343MigrationTest.java index 8795c96f2b3..aac7aba6c0d 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport343MigrationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport343MigrationTest.java @@ -70,6 +70,7 @@ public class JsonFileImport343MigrationTest extends AbstractJsonFileImportMigrat testMigrationTo24_x(false); testMigrationTo25_0_0(); testMigrationTo26_0_0(false); + testMigrationTo26_3_0(); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport483MigrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport483MigrationTest.java index 829d257577d..d3627174cd9 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport483MigrationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport483MigrationTest.java @@ -64,6 +64,7 @@ public class JsonFileImport483MigrationTest extends AbstractJsonFileImportMigrat testMigrationTo24_x(false); testMigrationTo25_0_0(); testMigrationTo26_0_0(false); + testMigrationTo26_3_0(); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport903MigrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport903MigrationTest.java index a4f42c4012c..163688d828d 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport903MigrationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport903MigrationTest.java @@ -57,6 +57,7 @@ public class JsonFileImport903MigrationTest extends AbstractJsonFileImportMigrat testMigrationTo24_x(false); testMigrationTo25_0_0(); testMigrationTo26_0_0(false); + testMigrationTo26_3_0(); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/MigrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/MigrationTest.java index 313367f1c2a..dc0b73c7354 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/MigrationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/MigrationTest.java @@ -73,6 +73,7 @@ public class MigrationTest extends AbstractMigrationTest { testMigrationTo25_0_0(); testMigrationTo26_0_0(true); testMigrationTo26_1_0(true); + testMigrationTo26_3_0(); } @Test @@ -87,5 +88,6 @@ public class MigrationTest extends AbstractMigrationTest { testMigrationTo25_0_0(); testMigrationTo26_0_0(true); testMigrationTo26_1_0(true); + testMigrationTo26_3_0(); } } diff --git a/themes/src/main/resources/theme/base/login/link-idp-action.ftl b/themes/src/main/resources/theme/base/login/link-idp-action.ftl new file mode 100644 index 00000000000..9b03e570cab --- /dev/null +++ b/themes/src/main/resources/theme/base/login/link-idp-action.ftl @@ -0,0 +1,15 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayMessage=false; section> + <#if section = "header"> + ${msg("linkIdpActionTitle", idpDisplayName)} + <#elseif section = "form"> + +
+ + +
+
+ + diff --git a/themes/src/main/resources/theme/base/login/messages/messages_en.properties b/themes/src/main/resources/theme/base/login/messages/messages_en.properties index 4a934dbd3a8..c2f772334f7 100644 --- a/themes/src/main/resources/theme/base/login/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/login/messages/messages_en.properties @@ -76,6 +76,9 @@ acceptTerms=I agree to the terms and conditions deleteCredentialTitle=Delete {0} deleteCredentialMessage=Do you want to delete {0}? +linkIdpActionTitle=Linking {0} +linkIdpActionMessage=Do you want to link your account with {0}? + recaptchaFailed=Invalid Recaptcha recaptchaNotConfigured=Recaptcha is required, but not configured consentDenied=Consent denied. diff --git a/themes/src/main/resources/theme/keycloak.v2/login/link-idp-action.ftl b/themes/src/main/resources/theme/keycloak.v2/login/link-idp-action.ftl new file mode 100644 index 00000000000..ca8889d577a --- /dev/null +++ b/themes/src/main/resources/theme/keycloak.v2/login/link-idp-action.ftl @@ -0,0 +1,21 @@ +<#import "template.ftl" as layout> +<#import "buttons.ftl" as buttons> + +<@layout.registrationLayout displayMessage=false; section> + + + <#if section = "header"> + ${msg("linkIdpActionTitle", idpDisplayName)} + <#elseif section = "form"> + + +
+ <@buttons.actionGroup> + <@buttons.button name="continue" id="kc-continue" label="doContinue" class=["kcButtonPrimaryClass"]/> + <@buttons.button name="cancel-aia" id="kc-cancel" label="doCancel" class=["kcButtonSecondaryClass"]/> + +
+