8000 Introducing IdpLinkAction as AIA to replace client-initiated account … by mposolda · Pull Request #38952 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/documentation/release_notes/topics/26_3_0.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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.

Original file line number Diff line number Diff line change
Expand Up @@ -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}.
Expand Down
18 changes: 7 additions & 11 deletions js/apps/account-ui/src/account-security/AccountRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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) => {
Expand All @@ -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`}
Expand Down Expand Up @@ -119,7 +111,11 @@ export const AccountRow = ({
<Button
id={`${account.providerAlias}-idp-link`}
variant="link"
=> link(account)}
=> {
login({
action: "idp_link:" + account.providerAlias,
});
}}
>
<Icon size="sm">
<LinkIcon />
Expand Down
25 changes: 0 additions & 25 deletions js/apps/account-ui/src/api/methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion js/apps/account-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ export {
getPermissionRequests,
getPersonalInfo,
getSupportedLocales,
linkAccount,
savePersonalInfo,
unLinkAccount,
} from "./api/methods";
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public interface RequiredActionContext {
enum Status {
CHALLENGE,
SUCCESS,
CANCELLED,
IGNORE,
FAILURE
}
Expand Down Expand Up @@ -149,6 +150,11 @@ default void failure() {
*/
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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ interface AuthenticationCallback {
*
* @return see description
*/
Response error(String message);
Response error(IdentityProviderModel idpConfig, String message);
}

C getConfig();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ public enum Action {
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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps have the idp_link as a enum in IdentityProviderModel similar to how they are defined for users?

10BC8
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure what you mean by idp_link being an enum. Do you mean that it should be constant instead of hardcoded string?


private final String alias;
private final Consumer<RealmModel> addAction;
Expand Down Expand Up @@ -222,6 +223,19 @@ public static void addDeleteCredentialAction(RealmModel realm) {
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,11 @@ public void success() {

}

@Override
public void cancel() {
status = Status.CANCELLED;
}

@Override
public void ignore() {
status = Status.IGNORE;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -526,11 +526,11 @@ public Response authResponse(@QueryParam(AbstractOAuth2IdentityProvider.OAUTH2_P
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);
}
}

Expand Down
Loading
0