mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-09 23:12:06 -03:30
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:
parent
43a8078cf9
commit
025b2ba442
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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}.
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -50,7 +50,6 @@ export {
|
||||
getPermissionRequests,
|
||||
getPersonalInfo,
|
||||
getSupportedLocales,
|
||||
linkAccount,
|
||||
savePersonalInfo,
|
||||
unLinkAccount,
|
||||
} from "./api/methods";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -145,6 +145,11 @@ public class RequiredActionContextResult implements RequiredActionContext {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cancel() {
|
||||
status = Status.CANCELLED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void ignore() {
|
||||
status = Status.IGNORE;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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()");
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
64
services/src/main/java/org/keycloak/utils/BrokerUtil.java
Normal file
64
services/src/main/java/org/keycloak/utils/BrokerUtil.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -70,6 +70,7 @@ public class JsonFileImport1903MigrationTest extends AbstractJsonFileImportMigra
|
||||
testMigrationTo24_x(true, true);
|
||||
testMigrationTo25_0_0();
|
||||
testMigrationTo26_0_0(true);
|
||||
testMigrationTo26_3_0();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@ -81,6 +81,7 @@ public class JsonFileImport198MigrationTest extends AbstractJsonFileImportMigrat
|
||||
testMigrationTo24_x(false);
|
||||
testMigrationTo25_0_0();
|
||||
testMigrationTo26_0_0(false);
|
||||
testMigrationTo26_3_0();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@ -75,6 +75,7 @@ public class JsonFileImport255MigrationTest extends AbstractJsonFileImportMigrat
|
||||
testMigrationTo24_x(false);
|
||||
testMigrationTo25_0_0();
|
||||
testMigrationTo26_0_0(false);
|
||||
testMigrationTo26_3_0();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -70,6 +70,7 @@ public class JsonFileImport343MigrationTest extends AbstractJsonFileImportMigrat
|
||||
testMigrationTo24_x(false);
|
||||
testMigrationTo25_0_0();
|
||||
testMigrationTo26_0_0(false);
|
||||
testMigrationTo26_3_0();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -64,6 +64,7 @@ public class JsonFileImport483MigrationTest extends AbstractJsonFileImportMigrat
|
||||
testMigrationTo24_x(false);
|
||||
testMigrationTo25_0_0();
|
||||
testMigrationTo26_0_0(false);
|
||||
testMigrationTo26_3_0();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -57,6 +57,7 @@ public class JsonFileImport903MigrationTest extends AbstractJsonFileImportMigrat
|
||||
testMigrationTo24_x(false);
|
||||
testMigrationTo25_0_0();
|
||||
testMigrationTo26_0_0(false);
|
||||
testMigrationTo26_3_0();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
@ -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.
|
||||
|
||||
@ -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>
|
||||
Loading…
x
Reference in New Issue
Block a user