Introducing IdpLinkAction as AIA to replace client-initiated account linking (#38952)

closes #37269
closes #35446

Signed-off-by: mposolda <mposolda@gmail.com>


Co-authored-by: andymunro <48995441+andymunro@users.noreply.github.com>
Signed-off-by: Marek Posolda <mposolda@gmail.com>
This commit is contained in:
Marek Posolda 2025-04-17 13:20:05 +02:00 committed by GitHub
parent 43a8078cf9
commit 025b2ba442
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1154 additions and 159 deletions

View File

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

View File

@ -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:<identity-provider-alias>` 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.

View File

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

View File

@ -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 (
<DataListItem
id={`${account.providerAlias}-idp`}
@ -119,7 +111,11 @@ export const AccountRow = ({
<Button
id={`${account.providerAlias}-idp-link`}
variant="link"
onClick={() => link(account)}
onClick={() => {
login({
action: "idp_link:" + account.providerAlias,
});
}}
>
<Icon size="sm">
<LinkIcon />

View File

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

View File

@ -50,7 +50,6 @@ export {
getPermissionRequests,
getPersonalInfo,
getSupportedLocales,
linkAccount,
savePersonalInfo,
unLinkAccount,
} from "./api/methods";

View File

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
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);
}
}

View File

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

View File

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

View File

@ -84,7 +84,7 @@ public interface IdentityProvider<C extends IdentityProviderModel> extends Provi
*
* @return see description
*/
Response error(String message);
Response error(IdentityProviderModel idpConfig, String message);
}
C getConfig();

View File

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

View File

@ -145,6 +145,11 @@ public class RequiredActionContextResult implements RequiredActionContext {
}
@Override
public void cancel() {
status = Status.CANCELLED;
}
@Override
public void ignore() {
status = Status.IGNORE;

View File

@ -526,11 +526,11 @@ public abstract class AbstractOAuth2IdentityProvider<C extends OAuth2IdentityPro
if (error.equals(ACCESS_DENIED)) {
return callback.cancelled(providerConfig);
} else if (error.equals(OAuthErrorException.LOGIN_REQUIRED) || error.equals(OAuthErrorException.INTERACTION_REQUIRED)) {
return callback.error(error);
return callback.error(providerConfig, error);
} else if (error.equals(OAuthErrorException.TEMPORARILY_UNAVAILABLE) && Constants.AUTHENTICATION_EXPIRED_MESSAGE.equals(errorDescription)) {
return callback.retryLogin(this.provider, authSession);
} else {
return callback.error(Messages.IDENTITY_PROVIDER_UNEXPECTED_ERROR);
return callback.error(providerConfig, Messages.IDENTITY_PROVIDER_UNEXPECTED_ERROR);
}
}

View File

@ -0,0 +1,229 @@
/*
* 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.broker.provider;
import java.io.IOException;
import java.util.Collections;
import jakarta.ws.rs.core.Response;
import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.authentication.InitiatedActionSupport;
import org.keycloak.authentication.RequiredActionContext;
import org.keycloak.authentication.RequiredActionFactory;
import org.keycloak.authentication.RequiredActionProvider;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.AccountRoles;
import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.FormMessage;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.ClientSessionCode;
import org.keycloak.services.resources.IdentityBrokerService;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.util.JsonSerialization;
import static org.keycloak.services.resources.IdentityBrokerService.LINKING_IDENTITY_PROVIDER;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
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<AuthenticationSessionModel> 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() {
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
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);
}
}
}

View File

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

View File

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

View File

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
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());
}
}

View File

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class KcOidcBrokerIdpLinkActionTest extends AbstractInitializedBaseBrokerTest {
private static final BrokerConfiguration BROKER_CONFIG_INSTANCE = new KcOidcBrokerConfiguration() {
@Override
public List<ClientRepresentation> createProviderClients() {
List<ClientRepresentation> 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<String, String> 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();
}
}

View File

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

View File

@ -70,6 +70,7 @@ public class JsonFileImport1903MigrationTest extends AbstractJsonFileImportMigra
testMigrationTo24_x(true, true);
testMigrationTo25_0_0();
testMigrationTo26_0_0(true);
testMigrationTo26_3_0();
}
@Test

View File

@ -81,6 +81,7 @@ public class JsonFileImport198MigrationTest extends AbstractJsonFileImportMigrat
testMigrationTo24_x(false);
testMigrationTo25_0_0();
testMigrationTo26_0_0(false);
testMigrationTo26_3_0();
}
@Override

View File

@ -75,6 +75,7 @@ public class JsonFileImport255MigrationTest extends AbstractJsonFileImportMigrat
testMigrationTo24_x(false);
testMigrationTo25_0_0();
testMigrationTo26_0_0(false);
testMigrationTo26_3_0();
}
}

View File

@ -70,6 +70,7 @@ public class JsonFileImport343MigrationTest extends AbstractJsonFileImportMigrat
testMigrationTo24_x(false);
testMigrationTo25_0_0();
testMigrationTo26_0_0(false);
testMigrationTo26_3_0();
}
}

View File

@ -64,6 +64,7 @@ public class JsonFileImport483MigrationTest extends AbstractJsonFileImportMigrat
testMigrationTo24_x(false);
testMigrationTo25_0_0();
testMigrationTo26_0_0(false);
testMigrationTo26_3_0();
}
}

View File

@ -57,6 +57,7 @@ public class JsonFileImport903MigrationTest extends AbstractJsonFileImportMigrat
testMigrationTo24_x(false);
testMigrationTo25_0_0();
testMigrationTo26_0_0(false);
testMigrationTo26_3_0();
}
}

View File

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

View File

@ -0,0 +1,15 @@
<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=false; section>
<#if section = "header">
${msg("linkIdpActionTitle", idpDisplayName)}
<#elseif section = "form">
<div id="kc-link-text">
${msg("linkIdpActionMessage", idpDisplayName)}
</div>
<form class="form-actions" action="${url.loginAction}" method="POST">
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}" name="continue" id="kc-continue" type="submit" value="${msg("doContinue")}"/>
<input class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonLargeClass!}" name="cancel-aia" value="${msg("doCancel")}" id="kc-cancel" type="submit" />
</form>
<div class="clearfix"></div>
</#if>
</@layout.registrationLayout>

View File

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

View File

@ -0,0 +1,21 @@
<#import "template.ftl" as layout>
<#import "buttons.ftl" as buttons>
<@layout.registrationLayout displayMessage=false; section>
<!-- template: link-idp-action.ftl -->
<#if section = "header">
${msg("linkIdpActionTitle", idpDisplayName)}
<#elseif section = "form">
<div id="kc-link-text" class="${properties.kcContentWrapperClass!}">
${msg("linkIdpActionMessage", idpDisplayName)}
</div>
<form class="${properties.kcFormClass!}" action="${url.loginAction}" method="POST">
<@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"]/>
</@buttons.actionGroup>
</form
</#if>
</@layout.registrationLayout>