VYPR
Medium severity5.0NVD Advisory· Published Apr 25, 2024· Updated Apr 15, 2026

CVE-2023-3597

CVE-2023-3597

Description

A flaw was found in Keycloak, where it does not correctly validate its client step-up authentication in org.keycloak.authentication. This flaw allows a remote user authenticated with a password to register a false second authentication factor along with an existing one and bypass authentication.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
org.keycloak:keycloak-servicesMaven
< 22.0.1022.0.10
org.keycloak:keycloak-servicesMaven
>= 23.0.0, < 24.0.324.0.3

Patches

1
aa634aee8828

CVE-2023-3597 - Secondary factor bypass in step-up authentication (#144)

https://github.com/keycloak/keycloakMarek PosoldaMar 23, 2024via ghsa
52 files changed · +1685 139
  • core/src/main/java/org/keycloak/util/TokenUtil.java+4 0 modified
    @@ -40,6 +40,10 @@ public class TokenUtil {
     
         public static final String TOKEN_TYPE_BEARER = "Bearer";
     
    +    // JWT Access Token types from https://datatracker.ietf.org/doc/html/rfc9068#section-2.1
    +    public static final String TOKEN_TYPE_JWT_ACCESS_TOKEN = "at+jwt";
    +    public static final String TOKEN_TYPE_JWT_ACCESS_TOKEN_PREFIXED = "application/" + TOKEN_TYPE_JWT_ACCESS_TOKEN;
    +
         public static final String TOKEN_TYPE_KEYCLOAK_ID = "Serialized-ID";
     
         public static final String TOKEN_TYPE_ID = "ID";
    
  • examples/providers/authenticator/src/main/java/org/keycloak/examples/authenticator/SecretQuestionRequiredAction.java+7 0 modified
    @@ -24,6 +24,8 @@
     import org.keycloak.examples.authenticator.credential.SecretQuestionCredentialModel;
     
     import jakarta.ws.rs.core.Response;
    +import org.keycloak.models.KeycloakSession;
    +import org.keycloak.sessions.AuthenticationSessionModel;
     
     /**
      * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
    @@ -37,6 +39,11 @@ public void evaluateTriggers(RequiredActionContext context) {
     
         }
     
    +    @Override
    +    public String getCredentialType(KeycloakSession session, AuthenticationSessionModel authenticationSession) {
    +        return SecretQuestionCredentialModel.TYPE;
    +    }
    +
         @Override
         public void requiredActionChallenge(RequiredActionContext context) {
             Response challenge = context.form().createForm("secret-question-config.ftl");
    
  • js/apps/account-ui/src/account-security/SigningIn.tsx+12 37 modified
    @@ -17,12 +17,10 @@ import {
     } from "@patternfly/react-core";
     import { CSSProperties, useState } from "react";
     import { Trans, useTranslation } from "react-i18next";
    -import { ContinueCancelModal, useAlerts } from "ui-shared";
    -import { deleteCredentials, getCredentials } from "../api/methods";
    +import { getCredentials } from "../api/methods";
     import {
       CredentialContainer,
       CredentialMetadataRepresentation,
    -  CredentialRepresentation,
     } from "../api/representations";
     import { EmptyRow } from "../components/datalist/EmptyRow";
     import useFormatter from "../components/format/format-date";
    @@ -66,14 +64,11 @@ const MobileLink = ({ title, onClick }: MobileLinkProps) => {
     const SigningIn = () => {
       const { t } = useTranslation();
       const { formatDate } = useFormatter();
    -  const { addAlert, addError } = useAlerts();
       const { login } = keycloak;
     
       const [credentials, setCredentials] = useState<CredentialContainer[]>();
    -  const [key, setKey] = useState(1);
    -  const refresh = () => setKey(key + 1);
     
    -  usePromise((signal) => getCredentials({ signal }), setCredentials, [key]);
    +  usePromise((signal) => getCredentials({ signal }), setCredentials, []);
     
       const credentialRowCells = (
         credMetadata: CredentialMetadataRepresentation,
    @@ -104,9 +99,6 @@ const SigningIn = () => {
         return items;
       };
     
    -  const label = (credential: CredentialRepresentation) =>
    -    credential.userLabel || t(credential.type as TFuncKey);
    -
       if (!credentials) {
         return <Spinner />;
       }
    @@ -170,34 +162,17 @@ const SigningIn = () => {
                             aria-labelledby={`cred-${meta.credential.id}`}
                           >
                             {container.removeable ? (
    -                          <ContinueCancelModal
    -                            buttonTitle="remove"
    -                            buttonVariant="danger"
    -                            modalTitle={t("removeCred", [
    -                              label(meta.credential),
    -                            ])}
    -                            modalMessage={t("stopUsingCred", [
    -                              label(meta.credential),
    -                            ])}
    -                            onContinue={async () => {
    -                              try {
    -                                await deleteCredentials(meta.credential);
    -                                addAlert(
    -                                  t("successRemovedMessage", {
    -                                    userLabel: label(meta.credential),
    -                                  }),
    -                                );
    -                                refresh();
    -                              } catch (error) {
    -                                addError(
    -                                  t("errorRemovedMessage", {
    -                                    userLabel: label(meta.credential),
    -                                    error,
    -                                  }).toString(),
    -                                );
    -                              }
    +                          <Button
    +                            variant="danger"
    +                            onClick={() => {
    +                              login({
    +                                action:
    +                                  "delete_credential:" + meta.credential.id,
    +                              });
                                 }}
    -                          />
    +                          >
    +                            {t("delete")}
    +                          </Button>
                             ) : (
                               <Button
                                 variant="secondary"
    
  • model/legacy-private/src/main/java/org/keycloak/migration/migrators/MigrateTo22_0_10.java+56 0 added
    @@ -0,0 +1,56 @@
    +/*
    + * Copyright 2024 Red Hat, Inc. and/or its affiliates
    + *  and other contributors as indicated by the @author tags.
    + *
    + *  Licensed under the Apache License, Version 2.0 (the "License");
    + *  you may not use this file except in compliance with the License.
    + *  You may obtain a copy of the License at
    + *
    + *  http://www.apache.org/licenses/LICENSE-2.0
    + *
    + *  Unless required by applicable law or agreed to in writing, software
    + *  distributed under the License is distributed on an "AS IS" BASIS,
    + *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + *
    + *  See the License for the specific language governing permissions and
    + *  limitations under the License.
    + *
    + */
    +
    +package org.keycloak.migration.migrators;
    +
    +import org.jboss.logging.Logger;
    +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 MigrateTo22_0_10 implements Migration {
    +
    +    private static final Logger LOG = Logger.getLogger(MigrateTo22_0_10.class);
    +
    +    public static final ModelVersion VERSION = new ModelVersion("22.0.10");
    +
    +    @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.addDeleteCredentialAction(realm);
    +    }
    +}
    
  • model/legacy-private/src/main/java/org/keycloak/storage/datastore/LegacyMigrationManager.java+3 1 modified
    @@ -36,6 +36,7 @@
     import org.keycloak.migration.migrators.MigrateTo1_9_2;
     import org.keycloak.migration.migrators.MigrateTo21_0_0;
     import org.keycloak.migration.migrators.MigrateTo22_0_0;
    +import org.keycloak.migration.migrators.MigrateTo22_0_10;
     import org.keycloak.migration.migrators.MigrateTo2_0_0;
     import org.keycloak.migration.migrators.MigrateTo2_1_0;
     import org.keycloak.migration.migrators.MigrateTo2_2_0;
    @@ -110,7 +111,8 @@ public class LegacyMigrationManager implements MigrationManager {
                 new MigrateTo18_0_0(),
                 new MigrateTo20_0_0(),
                 new MigrateTo21_0_0(),
    -            new MigrateTo22_0_0()
    +            new MigrateTo22_0_0(),
    +            new MigrateTo22_0_10()
         };
     
         private final KeycloakSession session;
    
  • server-spi-private/src/main/java/org/keycloak/authentication/AbstractAuthenticationFlowContext.java+6 0 modified
    @@ -21,6 +21,7 @@
     import org.keycloak.common.ClientConnection;
     import org.keycloak.events.EventBuilder;
     import org.keycloak.models.AuthenticationExecutionModel;
    +import org.keycloak.models.AuthenticationFlowModel;
     import org.keycloak.models.AuthenticatorConfigModel;
     import org.keycloak.models.KeycloakSession;
     import org.keycloak.models.RealmModel;
    @@ -57,6 +58,11 @@ public interface AbstractAuthenticationFlowContext {
          */
         AuthenticationExecutionModel getExecution();
     
    +    /**
    +     * @return the top level flow (root flow) of this authentication
    +     */
    +    AuthenticationFlowModel getTopLevelFlow();
    +
         /**
          * Current realm
          *
    
  • server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationFlowCallback.java+5 1 modified
    @@ -18,6 +18,8 @@
     
     package org.keycloak.authentication;
     
    +import org.keycloak.models.AuthenticationFlowModel;
    +
     /**
      * Callback to be triggered during various lifecycle events of authentication flow.
      *
    @@ -40,7 +42,9 @@ public interface AuthenticationFlowCallback extends Authenticator {
         /**
          * Triggered after the top authentication flow is successfully finished.
          * It is really suitable for last verification of successful authentication
    +     *
    +     * @param topFlow which was successfully finished
          */
    -    default void onTopFlowSuccess() {
    +    default void onTopFlowSuccess(AuthenticationFlowModel topFlow) {
         }
     }
    
  • server-spi-private/src/main/java/org/keycloak/authentication/CredentialAction.java+37 0 added
    @@ -0,0 +1,37 @@
    +/*
    + * Copyright 2024 Red Hat, Inc. and/or its affiliates
    + *  and other contributors as indicated by the @author tags.
    + *
    + *  Licensed under the Apache License, Version 2.0 (the "License");
    + *  you may not use this file except in compliance with the License.
    + *  You may obtain a copy of the License at
    + *
    + *  http://www.apache.org/licenses/LICENSE-2.0
    + *
    + *  Unless required by applicable law or agreed to in writing, software
    + *  distributed under the License is distributed on an "AS IS" BASIS,
    + *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + *
    + *  See the License for the specific language governing permissions and
    + *  limitations under the License.
    + *
    + */
    +
    +package org.keycloak.authentication;
    +
    +import org.keycloak.models.KeycloakSession;
    +import org.keycloak.sessions.AuthenticationSessionModel;
    +
    +/**
    + * Marking any required action implementation, that is supposed to work with user credentials
    + *
    + * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
    + */
    +public interface CredentialAction {
    +
    +    /**
    +     * @return credential type, which this action is able to register. This should refer to the same value as returned by {@link org.keycloak.credential.CredentialProvider#getType} of the
    +     * corresponding credential provider and {@link AuthenticatorFactory#getReferenceCategory()} of the corresponding authenticator
    +     */
    +    String getCredentialType(KeycloakSession session, AuthenticationSessionModel authenticationSession);
    +}
    
  • server-spi-private/src/main/java/org/keycloak/authentication/CredentialRegistrator.java+4 1 modified
    @@ -1,4 +1,7 @@
     package org.keycloak.authentication;
     
    -public interface CredentialRegistrator {
    +/**
    + * Marking implementation of the action, which is able to register credential of the particular type
    + */
    +public interface CredentialRegistrator extends CredentialAction {
     }
    
  • server-spi-private/src/main/java/org/keycloak/authentication/RequiredActionProvider.java+3 3 modified
    @@ -38,14 +38,14 @@ public interface RequiredActionProvider extends Provider {
         default InitiatedActionSupport initiatedActionSupport() {
             return InitiatedActionSupport.NOT_SUPPORTED;
         }
    -    
    +
         /**
          * Callback to let the action know that an application-initiated action
          * was canceled.
    -     * 
    +     *
          * @param session The Keycloak session.
          * @param authSession The authentication session.
    -     * 
    +     *
          */
         default void initiatedActionCanceled(KeycloakSession session, AuthenticationSessionModel authSession) {
             return;
    
  • server-spi-private/src/main/java/org/keycloak/events/Details.java+1 0 modified
    @@ -86,5 +86,6 @@ public interface Details {
     
         String CREDENTIAL_TYPE = "credential_type";
         String SELECTED_CREDENTIAL_ID = "selected_credential_id";
    +    String CREDENTIAL_ID = "credential_id";
         String AUTHENTICATION_ERROR_DETAIL = "authentication_error_detail";
     }
    
  • server-spi-private/src/main/java/org/keycloak/events/Errors.java+4 0 modified
    @@ -114,4 +114,8 @@ public interface Errors {
         String SLOW_DOWN = "slow_down";
         String GENERIC_AUTHENTICATION_ERROR= "generic_authentication_error";
     
    +    String CREDENTIAL_NOT_FOUND = "credential_not_found";
    +    String MISSING_CREDENTIAL_ID = "missing_credential_id";
    +    String DELETE_CREDENTIAL_FAILED = "delete_credential_failed";
    +
     }
    
  • server-spi-private/src/main/java/org/keycloak/models/Constants.java+2 0 modified
    @@ -82,6 +82,8 @@ public final class Constants {
         public static final String KEY = "key";
     
         public static final String KC_ACTION = "kc_action";
    +
    +    public static final String KC_ACTION_PARAMETER = "kc_action_parameter";
         public static final String KC_ACTION_STATUS = "kc_action_status";
         public static final String KC_ACTION_EXECUTING = "kc_action_executing";
         public static final int KC_ACTION_MAX_AGE = 300;
    
  • server-spi-private/src/main/java/org/keycloak/models/utils/DefaultRequiredActions.java+14 0 modified
    @@ -77,6 +77,7 @@ public enum Action {
             UPDATE_PASSWORD(UserModel.RequiredAction.UPDATE_PASSWORD.name(), DefaultRequiredActions::addUpdatePasswordAction),
             TERMS_AND_CONDITIONS(UserModel.RequiredAction.TERMS_AND_CONDITIONS.name(), DefaultRequiredActions::addTermsAndConditionsAction),
             DELETE_ACCOUNT("delete_account", DefaultRequiredActions::addDeleteAccountAction),
    +        DELETE_CREDENTIAL("delete_credential", DefaultRequiredActions::addDeleteCredentialAction),
             UPDATE_USER_LOCALE("update_user_locale", DefaultRequiredActions::addUpdateLocaleAction),
             UPDATE_EMAIL(UserModel.RequiredAction.UPDATE_EMAIL.name(), DefaultRequiredActions::addUpdateEmailAction, () -> isFeatureEnabled(Profile.Feature.UPDATE_EMAIL)),
             CONFIGURE_RECOVERY_AUTHN_CODES(UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name(), DefaultRequiredActions::addRecoveryAuthnCodesAction, () -> isFeatureEnabled(Profile.Feature.RECOVERY_CODES)),
    @@ -195,6 +196,19 @@ public static void addDeleteAccountAction(RealmModel realm) {
             }
         }
     
    +    public static void addDeleteCredentialAction(RealmModel realm) {
    +        if (realm.getRequiredActionProviderByAlias("delete_credential") == null) {
    +            RequiredActionProviderModel deleteCredential = new RequiredActionProviderModel();
    +            deleteCredential.setEnabled(true);
    +            deleteCredential.setAlias("delete_credential");
    +            deleteCredential.setName("Delete Credential");
    +            deleteCredential.setProviderId("delete_credential");
    +            deleteCredential.setDefaultAction(false);
    +            deleteCredential.setPriority(100);
    +            realm.addRequiredActionProvider(deleteCredential);
    +        }
    +    }
    +
         public static void addUpdateLocaleAction(RealmModel realm) {
             if (realm.getRequiredActionProviderByAlias("update_user_locale") == null) {
                 RequiredActionProviderModel updateUserLocale = new RequiredActionProviderModel();
    
  • services/src/main/java/org/keycloak/authentication/AuthenticationProcessor.java+5 0 modified
    @@ -347,6 +347,11 @@ public AuthenticationExecutionModel getExecution() {
                 return execution;
             }
     
    +        @Override
    +        public AuthenticationFlowModel getTopLevelFlow() {
    +            return AuthenticatorUtil.getTopParentFlow(realm, execution);
    +        }
    +
             @Override
             public AuthenticatorConfigModel getAuthenticatorConfig() {
                 if (execution.getAuthenticatorConfig() == null) return null;
    
  • services/src/main/java/org/keycloak/authentication/authenticators/browser/CookieAuthenticator.java+7 2 modified
    @@ -51,7 +51,7 @@ public void authenticate(AuthenticationFlowContext context) {
                 LoginProtocol protocol = context.getSession().getProvider(LoginProtocol.class, authSession.getProtocol());
                 authSession.setAuthNote(Constants.LOA_MAP, authResult.getSession().getNote(Constants.LOA_MAP));
                 context.setUser(authResult.getUser());
    -            AcrStore acrStore = new AcrStore(authSession);
    +            AcrStore acrStore = new AcrStore(context.getSession(), authSession);
     
                 // Cookie re-authentication is skipped if re-authentication is required
                 if (protocol.requireReauthentication(authResult.getSession(), authSession)) {
    @@ -62,10 +62,15 @@ public void authenticate(AuthenticationFlowContext context) {
                     context.attempted();
                 } else {
                     int previouslyAuthenticatedLevel = acrStore.getHighestAuthenticatedLevelFromPreviousAuthentication();
    -                if (acrStore.getRequestedLevelOfAuthentication() > previouslyAuthenticatedLevel) {
    +                if (acrStore.getRequestedLevelOfAuthentication(context.getTopLevelFlow()) > previouslyAuthenticatedLevel) {
                         // Step-up authentication, we keep the loa from the existing user session.
                         // The cookie alone is not enough and other authentications must follow.
                         acrStore.setLevelAuthenticatedToCurrentRequest(previouslyAuthenticatedLevel);
    +
    +                    if (authSession.getClientNote(Constants.KC_ACTION) != null) {
    +                        context.setForwardedInfoMessage(Messages.AUTHENTICATE_STRONG);
    +                    }
    +
                         context.attempted();
                     } else {
                         // Cookie only authentication
    
  • services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalLoaAuthenticator.java+8 8 modified
    @@ -22,9 +22,9 @@
     import org.keycloak.authentication.AuthenticationFlowContext;
     import org.keycloak.authentication.AuthenticationFlowError;
     import org.keycloak.authentication.AuthenticationFlowException;
    -import org.keycloak.authentication.AuthenticatorUtil;
     import org.keycloak.authentication.authenticators.util.AcrStore;
     import org.keycloak.authentication.authenticators.util.LoAUtil;
    +import org.keycloak.models.AuthenticationFlowModel;
     import org.keycloak.models.Constants;
     import org.keycloak.models.KeycloakSession;
     import org.keycloak.models.RealmModel;
    @@ -52,11 +52,11 @@ public ConditionalLoaAuthenticator(KeycloakSession session) {
         @Override
         public boolean matchCondition(AuthenticationFlowContext context) {
             AuthenticationSessionModel authSession = context.getAuthenticationSession();
    -        AcrStore acrStore = new AcrStore(authSession);
    +        AcrStore acrStore = new AcrStore(context.getSession(), authSession);
             int currentAuthenticationLoa = acrStore.getLevelOfAuthenticationFromCurrentAuthentication();
             Integer configuredLoa = getConfiguredLoa(context);
             if (configuredLoa == null) configuredLoa = Constants.MINIMUM_LOA;
    -        int requestedLoa = acrStore.getRequestedLevelOfAuthentication();
    +        int requestedLoa = acrStore.getRequestedLevelOfAuthentication(context.getTopLevelFlow());
             if (currentAuthenticationLoa < Constants.MINIMUM_LOA) {
                 logger.tracef("Condition '%s' evaluated to true due the user not yet reached any authentication level in this session, configuredLoa: %d, requestedLoa: %d",
                         context.getAuthenticatorConfig().getAlias(), configuredLoa, requestedLoa);
    @@ -84,7 +84,7 @@ public boolean matchCondition(AuthenticationFlowContext context) {
         @Override
         public void onParentFlowSuccess(AuthenticationFlowContext context) {
             AuthenticationSessionModel authSession = context.getAuthenticationSession();
    -        AcrStore acrStore = new AcrStore(authSession);
    +        AcrStore acrStore = new AcrStore(context.getSession(), authSession);
     
             Integer newLoa = getConfiguredLoa(context);
             if (newLoa == null) {
    @@ -102,14 +102,14 @@ public void onParentFlowSuccess(AuthenticationFlowContext context) {
         }
     
         @Override
    -    public void onTopFlowSuccess() {
    +    public void onTopFlowSuccess(AuthenticationFlowModel topFlow) {
             AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession();
    -        AcrStore acrStore = new AcrStore(authSession);
    +        AcrStore acrStore = new AcrStore(session, authSession);
     
             logger.tracef("Finished authentication at level %d when authenticating authSession '%s'.", acrStore.getLevelOfAuthenticationFromCurrentAuthentication(), authSession.getParentSession().getId());
    -        if (acrStore.isLevelOfAuthenticationForced() && !acrStore.isLevelOfAuthenticationSatisfiedFromCurrentAuthentication()) {
    +        if (acrStore.isLevelOfAuthenticationForced() && !acrStore.isLevelOfAuthenticationSatisfiedFromCurrentAuthentication(topFlow)) {
                 String details = String.format("Forced level of authentication did not meet the requirements. Requested level: %d, Fulfilled level: %d",
    -                    acrStore.getRequestedLevelOfAuthentication(), acrStore.getLevelOfAuthenticationFromCurrentAuthentication());
    +                    acrStore.getRequestedLevelOfAuthentication(topFlow), acrStore.getLevelOfAuthenticationFromCurrentAuthentication());
                 throw new AuthenticationFlowException(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR, details, Messages.ACR_NOT_FULFILLED);
             }
     
    
  • services/src/main/java/org/keycloak/authentication/authenticators/util/AcrStore.java+73 9 modified
    @@ -20,18 +20,28 @@
     
     import java.io.IOException;
     import java.util.HashMap;
    +import java.util.List;
     import java.util.Map;
     import java.util.TreeMap;
     
     import com.fasterxml.jackson.core.type.TypeReference;
     import org.jboss.logging.Logger;
     import org.keycloak.authentication.AuthenticatorUtil;
    +import org.keycloak.authentication.CredentialAction;
    +import org.keycloak.authentication.RequiredActionProvider;
    +import org.keycloak.common.util.MultivaluedHashMap;
     import org.keycloak.common.util.Time;
     import org.keycloak.models.AuthenticatedClientSessionModel;
    +import org.keycloak.models.AuthenticationFlowModel;
     import org.keycloak.models.Constants;
    +import org.keycloak.models.KeycloakSession;
    +import org.keycloak.models.RealmModel;
    +import org.keycloak.models.UserModel;
     import org.keycloak.sessions.AuthenticationSessionModel;
     import org.keycloak.util.JsonSerialization;
     
    +import static org.keycloak.models.Constants.NO_LOA;
    +
     /**
      * CRUD data in the authentication session, which are related to step-up authentication
      *
    @@ -41,9 +51,11 @@ public class AcrStore {
     
         private static final Logger logger = Logger.getLogger(AcrStore.class);
     
    +    private final KeycloakSession session;
         private final AuthenticationSessionModel authSession;
     
    -    public AcrStore(AuthenticationSessionModel authSession) {
    +    public AcrStore(KeycloakSession session, AuthenticationSessionModel authSession) {
    +        this.session = session;
             this.authSession = authSession;
         }
     
    @@ -53,21 +65,73 @@ public boolean isLevelOfAuthenticationForced() {
         }
     
     
    -    public int getRequestedLevelOfAuthentication() {
    +    public int getRequestedLevelOfAuthentication(AuthenticationFlowModel executionModel) {
             String requiredLoa = authSession.getClientNote(Constants.REQUESTED_LEVEL_OF_AUTHENTICATION);
    -        return requiredLoa == null ? Constants.NO_LOA : Integer.parseInt(requiredLoa);
    +        int requestedLoaByClient = requiredLoa == null ? NO_LOA : Integer.parseInt(requiredLoa);
    +        int requestedLoaByKcAction = getRequestedLevelOfAuthenticationByKcAction(executionModel);
    +        logger.tracef("Level requested by client: %d, level requested by kc_action parameter: %d", requestedLoaByClient, requestedLoaByKcAction);
    +        return Math.max(requestedLoaByClient, requestedLoaByKcAction);
    +    }
    +
    +    //
    +    private int getRequestedLevelOfAuthenticationByKcAction(AuthenticationFlowModel topLevelFlow) {
    +        RealmModel realm = authSession.getRealm();
    +        UserModel user = authSession.getAuthenticatedUser();
    +        String kcAction = authSession.getClientNote(Constants.KC_ACTION);
    +        if (user != null && kcAction != null) {
    +            RequiredActionProvider reqAction = session.getProvider(RequiredActionProvider.class, kcAction);
    +            if (reqAction instanceof CredentialAction) {
    +                String credentialType = ((CredentialAction) reqAction).getCredentialType(session, authSession);
    +                if (credentialType != null) {
    +                    Map<String, Integer> credentialTypesToLoa = LoAUtil.getCredentialTypesToLoAMap(session, realm, topLevelFlow);
    +
    +                    Integer credentialTypeLevel = credentialTypesToLoa.get(credentialType);
    +                    if (credentialTypeLevel != null) {
    +                        // We check if user has any credentials of given type available. For instance if user doesn't yet have any 2nd-factor configured, we don't request level2 from him
    +                        MultivaluedHashMap<Integer, String> loaToCredentialTypes = reverse(credentialTypesToLoa);
    +                        return getHighestLevelAvailableForUser(user, loaToCredentialTypes, credentialTypeLevel);
    +                    }
    +                }
    +            }
    +        }
    +        return NO_LOA;
    +    }
    +
    +    private MultivaluedHashMap<Integer, String> reverse(Map<String, Integer> orig) {
    +        MultivaluedHashMap<Integer, String> reverse = new MultivaluedHashMap<>();
    +        orig.forEach((key, value) -> reverse.add(value, key));
    +        return reverse;
         }
     
    +    private Integer getHighestLevelAvailableForUser(UserModel user, MultivaluedHashMap<Integer, String> loaToCredentialTypes, int levelToTry) {
    +        if (levelToTry <= NO_LOA) return levelToTry;
    +
    +        List<String> currentLevelCredentialTypes = loaToCredentialTypes.get(levelToTry);
    +        if (currentLevelCredentialTypes == null || currentLevelCredentialTypes.isEmpty()) {
    +            // No credentials required for authentication on this level
    +            return levelToTry;
    +        }
    +
    +        boolean hasCredentialOfLevel = user.credentialManager().getStoredCredentialsStream()
    +                .anyMatch(credentialModel -> currentLevelCredentialTypes.contains(credentialModel.getType()));
    +        if (hasCredentialOfLevel) {
    +            logger.tracef("User %s has credential of level %d available", user.getUsername(), levelToTry);
    +            return levelToTry;
    +        } else {
    +            // Fallback to lower level
    +            return getHighestLevelAvailableForUser(user, loaToCredentialTypes, levelToTry - 1);
    +        }
    +    }
     
    -    public boolean isLevelOfAuthenticationSatisfiedFromCurrentAuthentication() {
    -        return getRequestedLevelOfAuthentication()
    +    public boolean isLevelOfAuthenticationSatisfiedFromCurrentAuthentication(AuthenticationFlowModel topFlow) {
    +        return getRequestedLevelOfAuthentication(topFlow)
                     <= getAuthenticatedLevelCurrentAuthentication();
         }
     
     
         public static int getCurrentLevelOfAuthentication(AuthenticatedClientSessionModel clientSession) {
             String clientSessionLoaNote = clientSession.getNote(Constants.LEVEL_OF_AUTHENTICATION);
    -        return clientSessionLoaNote == null ? Constants.NO_LOA : Integer.parseInt(clientSessionLoaNote);
    +        return clientSessionLoaNote == null ? NO_LOA : Integer.parseInt(clientSessionLoaNote);
         }
     
     
    @@ -101,7 +165,7 @@ public boolean isLevelAuthenticatedInPreviousAuth(int level, int maxAge) {
          */
         public int getLevelOfAuthenticationFromCurrentAuthentication() {
             String authSessionLoaNote = authSession.getAuthNote(Constants.LEVEL_OF_AUTHENTICATION);
    -        return authSessionLoaNote == null ? Constants.NO_LOA : Integer.parseInt(authSessionLoaNote);
    +        return authSessionLoaNote == null ? NO_LOA : Integer.parseInt(authSessionLoaNote);
         }
     
     
    @@ -137,7 +201,7 @@ private void setLevelAuthenticatedToMap(int level) {
     
         private int getAuthenticatedLevelCurrentAuthentication() {
             String authSessionLoaNote = authSession.getAuthNote(Constants.LEVEL_OF_AUTHENTICATION);
    -        return authSessionLoaNote == null ? Constants.NO_LOA : Integer.parseInt(authSessionLoaNote);
    +        return authSessionLoaNote == null ? NO_LOA : Integer.parseInt(authSessionLoaNote);
         }
     
         /**
    @@ -146,7 +210,7 @@ private int getAuthenticatedLevelCurrentAuthentication() {
         public int getHighestAuthenticatedLevelFromPreviousAuthentication() {
             // No map found. User was not yet authenticated in this session
             Map<Integer, Integer> levels = getCurrentAuthenticatedLevelsMap();
    -        if (levels == null || levels.isEmpty()) return Constants.NO_LOA;
    +        if (levels == null || levels.isEmpty()) return NO_LOA;
     
             // Map was already saved, so it is SSO authentication at minimum. Using "0" level as the minimum level in this case
             int maxLevel = Constants.MINIMUM_LOA;
    
  • services/src/main/java/org/keycloak/authentication/authenticators/util/LoAUtil.java+70 1 modified
    @@ -19,22 +19,33 @@
     package org.keycloak.authentication.authenticators.util;
     
     import java.util.Collections;
    +import java.util.HashMap;
     import java.util.List;
     import java.util.Map;
     import java.util.Objects;
    +import java.util.Set;
    +import java.util.concurrent.ConcurrentHashMap;
    +import java.util.concurrent.atomic.AtomicReference;
     import java.util.stream.Collectors;
     import java.util.stream.Stream;
     
     import org.jboss.logging.Logger;
    +import org.keycloak.authentication.Authenticator;
    +import org.keycloak.authentication.AuthenticatorFactory;
     import org.keycloak.authentication.AuthenticatorUtil;
     import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticator;
     import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticatorFactory;
    +import org.keycloak.credential.CredentialProvider;
     import org.keycloak.models.AuthenticatedClientSessionModel;
     import org.keycloak.models.AuthenticationExecutionModel;
    +import org.keycloak.models.AuthenticationFlowModel;
     import org.keycloak.models.AuthenticatorConfigModel;
     import org.keycloak.models.Constants;
    +import org.keycloak.models.KeycloakSession;
     import org.keycloak.models.RealmModel;
     
    +import static org.keycloak.models.Constants.NO_LOA;
    +
     /**
      * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
      */
    @@ -48,7 +59,7 @@ public class LoAUtil {
          */
         public static int getCurrentLevelOfAuthentication(AuthenticatedClientSessionModel clientSession) {
             String clientSessionLoaNote = clientSession.getNote(Constants.LEVEL_OF_AUTHENTICATION);
    -        return clientSessionLoaNote == null ? Constants.NO_LOA : Integer.parseInt(clientSessionLoaNote);
    +        return clientSessionLoaNote == null ? NO_LOA : Integer.parseInt(clientSessionLoaNote);
         }
     
     
    @@ -119,4 +130,62 @@ public static int getMaxAgeFromLoaConditionConfiguration(AuthenticatorConfigMode
                 return 0;
             }
         }
    +
    +    /**
    +     * Return map where:
    +     *  - keys are credential types corresponding to authenticators available in given authentication flow
    +     *  - values are LoA levels of those credentials in the given flow (If not step-up authentication is used, values will be always Constants.NO_LOA)
    +     *
    +     *  For instance if we have password as level1 and OTP or WebAuthn as available level2 authenticators it can return map like:
    +     *   { "password" -> 1,
    +     *     "otp" -> 2
    +     *     "webauthn" -> 2
    +     *   }
    +     *
    +     * @param session
    +     * @param realm
    +     * @param topFlow
    +     * @return map as described above. Never returns null, but can return empty map.
    +     */
    +    public static Map<String, Integer> getCredentialTypesToLoAMap(KeycloakSession session, RealmModel realm, AuthenticationFlowModel topFlow) {
    +        Map<String, Integer> result = new HashMap<>();
    +        AtomicReference<Integer> currentLevel = new AtomicReference<>(NO_LOA);
    +        Set<String> availableCredentialTypes = AuthenticatorUtil.getCredentialProviders(session)
    +                .map(CredentialProvider::getType)
    +                .collect(Collectors.toSet());
    +
    +        fillCredentialsToLoAMap(session, realm, topFlow, availableCredentialTypes, currentLevel, result);
    +
    +        logger.tracef("Computed credential types to LoA map for authentication flow '%s' in realm '%s'. Mapping: %s", topFlow.getAlias(), realm.getName(), result);
    +
    +        return result;
    +    }
    +
    +    private static void fillCredentialsToLoAMap(KeycloakSession session, RealmModel realm, AuthenticationFlowModel authFlow, Set<String> availableCredentialTypes, AtomicReference<Integer> currentLevel, Map<String, Integer> result) {
    +        realm.getAuthenticationExecutionsStream(authFlow.getId()).forEachOrdered(execution -> {
    +            if (execution.isAuthenticatorFlow()) {
    +                AuthenticationFlowModel subFlow = realm.getAuthenticationFlowById(execution.getFlowId());
    +
    +                int levelWhenExecuted = currentLevel.get();
    +                fillCredentialsToLoAMap(session, realm, subFlow, availableCredentialTypes, currentLevel, result);
    +                currentLevel.set(levelWhenExecuted); // Subflow is finished. We should "reset" current level and set it to the same value before we started to process the subflow
    +            } else {
    +                if (ConditionalLoaAuthenticatorFactory.PROVIDER_ID.equals(execution.getAuthenticator())) {
    +                    AuthenticatorConfigModel loaConditionConfig = realm.getAuthenticatorConfigById(execution.getAuthenticatorConfig());
    +                    Integer level = getLevelFromLoaConditionConfiguration(loaConditionConfig);
    +                    if (level != null) {
    +                        currentLevel.set(level);
    +                    }
    +                } else {
    +                    AuthenticatorFactory factory = (AuthenticatorFactory) session.getKeycloakSessionFactory().getProviderFactory(Authenticator.class, execution.getAuthenticator());
    +                    if (factory == null) return;
    +                    // reference-category points to the credentialType
    +                    if (factory.getReferenceCategory() != null && availableCredentialTypes.contains(factory.getReferenceCategory())) {
    +                        result.put(factory.getReferenceCategory(), currentLevel.get());
    +                    }
    +                }
    +            }
    +        });
    +    }
    +
     }
    
  • services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java+38 0 modified
    @@ -22,8 +22,12 @@
     import org.keycloak.authentication.actiontoken.ActionTokenContext;
     import org.keycloak.authentication.actiontoken.DefaultActionToken;
     import org.keycloak.common.ClientConnection;
    +import org.keycloak.common.util.reflections.Types;
    +import org.keycloak.credential.CredentialProvider;
    +import org.keycloak.credential.CredentialProviderFactory;
     import org.keycloak.http.HttpRequest;
     import org.keycloak.models.AuthenticationExecutionModel;
    +import org.keycloak.models.AuthenticationFlowModel;
     import org.keycloak.models.Constants;
     import org.keycloak.models.KeycloakSession;
     import org.keycloak.models.RealmModel;
    @@ -38,6 +42,7 @@
     import java.util.Objects;
     import java.util.Set;
     import java.util.stream.Collectors;
    +import java.util.stream.Stream;
     
     import static org.keycloak.services.managers.AuthenticationManager.FORCED_REAUTHENTICATION;
     import static org.keycloak.services.managers.AuthenticationManager.SSO_AUTH;
    @@ -119,6 +124,29 @@ public static List<AuthenticationExecutionModel> getExecutionsByType(RealmModel
             return executions;
         }
     
    +    /**
    +     * Useful if we need to find top-level flow from executionModel
    +     *
    +     * @param realm
    +     * @param executionModel
    +     * @return Top parent flow corresponding to given executionModel.
    +     */
    +    public static AuthenticationFlowModel getTopParentFlow(RealmModel realm, AuthenticationExecutionModel executionModel) {
    +        if (executionModel.getParentFlow() != null) {
    +            AuthenticationFlowModel flow = realm.getAuthenticationFlowById(executionModel.getParentFlow());
    +            if (flow == null) throw new IllegalStateException("Flow '" + executionModel.getParentFlow() + "' referenced from execution '" + executionModel.getId() + "' not found in realm " + realm.getName());
    +            if (flow.isTopLevel()) return flow;
    +
    +            AuthenticationExecutionModel execution = realm.getAuthenticationExecutionByFlowId(flow.getId());
    +            if (execution == null) throw new IllegalStateException("Not found execution referenced by flow '" + flow.getId() + "' in realm " + realm.getName());
    +            return getTopParentFlow(realm, execution);
    +        } else {
    +            throw new IllegalStateException("Execution '" + executionModel.getId() + "' does not have parent flow in realm " + realm.getName());
    +        }
    +    }
    +
    +
    +
         /**
          * Logouts all sessions that are different to the current authentication session
          * managed in the action context.
    @@ -150,4 +178,14 @@ private static void logoutOtherSessions(KeycloakSession session, RealmModel real
                             conn, req.getHttpHeaders(), true)
                     );
         }
    +
    +    /**
    +     * @param session
    +     * @return all credential providers available
    +     */
    +    public static Stream<CredentialProvider> getCredentialProviders(KeycloakSession session) {
    +        return session.getKeycloakSessionFactory().getProviderFactoriesStream(CredentialProvider.class)
    +                .filter(f -> Types.supports(CredentialProvider.class, f, CredentialProviderFactory.class))
    +                .map(f -> session.getProvider(CredentialProvider.class, f.getId()));
    +    }
     }
    
  • services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java+1 1 modified
    @@ -588,6 +588,6 @@ private void executeTopFlowSuccessCallbacks() {
                     .filter(Objects::nonNull)
                     .filter(AuthenticationFlowCallback.class::isInstance)
                     .map(AuthenticationFlowCallback.class::cast)
    -                .forEach(AuthenticationFlowCallback::onTopFlowSuccess);
    +                .forEach(callback -> callback.onTopFlowSuccess(flow));
         }
     }
    
  • services/src/main/java/org/keycloak/authentication/requiredactions/DeleteCredentialAction.java+192 0 added
    @@ -0,0 +1,192 @@
    +/*
    + * Copyright 2024 Red Hat, Inc. and/or its affiliates
    + *  and other contributors as indicated by the @author tags.
    + *
    + *  Licensed under the Apache License, Version 2.0 (the "License");
    + *  you may not use this file except in compliance with the License.
    + *  You may obtain a copy of the License at
    + *
    + *  http://www.apache.org/licenses/LICENSE-2.0
    + *
    + *  Unless required by applicable law or agreed to in writing, software
    + *  distributed under the License is distributed on an "AS IS" BASIS,
    + *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + *
    + *  See the License for the specific language governing permissions and
    + *  limitations under the License.
    + *
    + */
    +
    +package org.keycloak.authentication.requiredactions;
    +
    +
    +import jakarta.ws.rs.WebApplicationException;
    +import jakarta.ws.rs.core.Response;
    +import org.keycloak.Config;
    +import org.keycloak.authentication.CredentialAction;
    +import org.keycloak.authentication.InitiatedActionSupport;
    +import org.keycloak.authentication.RequiredActionContext;
    +import org.keycloak.authentication.RequiredActionFactory;
    +import org.keycloak.authentication.RequiredActionProvider;
    +import org.keycloak.authentication.authenticators.util.AcrStore;
    +import org.keycloak.authentication.requiredactions.util.CredentialDeleteHelper;
    +import org.keycloak.credential.CredentialModel;
    +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.Constants;
    +import org.keycloak.models.KeycloakSession;
    +import org.keycloak.models.KeycloakSessionFactory;
    +import org.keycloak.models.UserModel;
    +import org.keycloak.models.credential.OTPCredentialModel;
    +import org.keycloak.sessions.AuthenticationSessionModel;
    +import org.keycloak.utils.StringUtil;
    +
    +/**
    + * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
    + */
    +public class DeleteCredentialAction implements RequiredActionProvider, RequiredActionFactory, CredentialAction {
    +
    +    public static final String PROVIDER_ID = "delete_credential";
    +
    +    @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 String getCredentialType(KeycloakSession session, AuthenticationSessionModel authenticationSession) {
    +        String credentialId = authenticationSession.getClientNote(Constants.KC_ACTION_PARAMETER);
    +        if (credentialId == null) {
    +            return null;
    +        }
    +
    +        UserModel user = authenticationSession.getAuthenticatedUser();
    +        if (user == null) {
    +            return null;
    +        }
    +
    +        CredentialModel credential = user.credentialManager().getStoredCredentialById(credentialId);
    +        if (credential == null) {
    +            if (credentialId.endsWith("-id")) {
    +                return credentialId.substring(0, credentialId.length() - 3);
    +            } else {
    +                return null;
    +            }
    +        } else {
    +            return credential.getType();
    +        }
    +    }
    +
    +    @Override
    +    public void requiredActionChallenge(RequiredActionContext context) {
    +        String credentialId = context.getAuthenticationSession().getClientNote(Constants.KC_ACTION_PARAMETER);
    +        UserModel user = context.getUser();
    +        if (credentialId == null) {
    +            context.getEvent()
    +                    .error(Errors.MISSING_CREDENTIAL_ID);
    +            context.ignore();
    +            return;
    +        }
    +
    +        String credentialLabel;
    +        CredentialModel credential = user.credentialManager().getStoredCredentialById(credentialId);
    +        if (credential == null) {
    +            // Backwards compatibility with account console 1 - When stored credential is not found, it may be federated credential.
    +            // In this case, it's ID needs to be something like "otp-id", which is returned by account REST GET endpoint as a placeholder
    +            // for federated credentials (See CredentialHelper.createUserStorageCredentialRepresentation )
    +            if (credentialId.endsWith("-id")) {
    +                credentialLabel = credentialId.substring(0, credentialId.length() - 3);
    +            } else {
    +                context.getEvent()
    +                        .detail(Details.CREDENTIAL_ID, credentialId)
    +                        .error(Errors.CREDENTIAL_NOT_FOUND);
    +                context.ignore();
    +                return;
    +            }
    +        } else {
    +            credentialLabel = StringUtil.isNotBlank(credential.getUserLabel()) ? credential.getUserLabel() : credential.getType();
    +        }
    +
    +        Response challenge = context.form()
    +                .setAttribute("credentialLabel", credentialLabel)
    +                .createForm("delete-credential.ftl");
    +        context.challenge(challenge);
    +    }
    +
    +    private void setupEvent(CredentialModel credential, EventBuilder event) {
    +        if (credential != null) {
    +            if (OTPCredentialModel.TYPE.equals(credential.getType())) {
    +                event.event(EventType.REMOVE_TOTP);
    +            }
    +            event.detail(Details.CREDENTIAL_TYPE, credential.getType())
    +                    .detail(Details.CREDENTIAL_ID, credential.getId());
    +        }
    +    }
    +
    +    @Override
    +    public void processAction(RequiredActionContext context) {
    +        EventBuilder event = context.getEvent();
    +        String credentialId = context.getAuthenticationSession().getClientNote(Constants.KC_ACTION_PARAMETER);
    +
    +        CredentialModel credential = context.getUser().credentialManager().getStoredCredentialById(credentialId);
    +        setupEvent(credential, event);
    +
    +        try {
    +            CredentialDeleteHelper.removeCredential(context.getSession(), context.getUser(), credentialId, () -> getCurrentLoa(context.getSession(), context.getAuthenticationSession()));
    +            context.success();
    +
    +        } catch (WebApplicationException wae) {
    +            Response response = context.getSession().getProvider(LoginFormsProvider.class)
    +                    .setAuthenticationSession(context.getAuthenticationSession())
    +                    .setUser(context.getUser())
    +                    .setError(wae.getMessage())
    +                    .createErrorPage(Response.Status.BAD_REQUEST);
    +            event.detail(Details.REASON, wae.getMessage())
    +                    .error(Errors.DELETE_CREDENTIAL_FAILED);
    +            context.challenge(response);
    +        }
    +    }
    +
    +    private int getCurrentLoa(KeycloakSession session, AuthenticationSessionModel authSession) {
    +        return new AcrStore(session, authSession).getLevelOfAuthenticationFromCurrentAuthentication();
    +    }
    +
    +    @Override
    +    public String getDisplayText() {
    +        return "Delete Credential";
    +    }
    +
    +    @Override
    +    public void close() {
    +
    +    }
    +}
    
  • services/src/main/java/org/keycloak/authentication/requiredactions/RecoveryAuthnCodesAction.java+8 1 modified
    @@ -5,6 +5,7 @@
     import java.util.List;
     import org.keycloak.Config;
     import org.keycloak.authentication.AuthenticatorUtil;
    +import org.keycloak.authentication.CredentialRegistrator;
     import org.keycloak.authentication.InitiatedActionSupport;
     import org.keycloak.authentication.RequiredActionContext;
     import org.keycloak.authentication.RequiredActionFactory;
    @@ -21,8 +22,9 @@
     
     import jakarta.ws.rs.core.MultivaluedMap;
     import jakarta.ws.rs.core.Response;
    +import org.keycloak.sessions.AuthenticationSessionModel;
     
    -public class RecoveryAuthnCodesAction implements RequiredActionProvider, RequiredActionFactory, EnvironmentDependentProviderFactory {
    +public class RecoveryAuthnCodesAction implements RequiredActionProvider, RequiredActionFactory, EnvironmentDependentProviderFactory, CredentialRegistrator {
     
         private static final String FIELD_GENERATED_RECOVERY_AUTHN_CODES_HIDDEN = "generatedRecoveryAuthnCodes";
         private static final String FIELD_GENERATED_AT_HIDDEN = "generatedAt";
    @@ -35,6 +37,11 @@ public String getId() {
             return PROVIDER_ID;
         }
     
    +    @Override
    +    public String getCredentialType(KeycloakSession session, AuthenticationSessionModel authenticationSession) {
    +        return RecoveryAuthnCodesCredentialModel.TYPE;
    +    }
    +
         @Override
         public String getDisplayText() {
             return "Recovery Authentication Codes";
    
  • services/src/main/java/org/keycloak/authentication/requiredactions/UpdateTotp.java+6 0 modified
    @@ -38,6 +38,7 @@
     import org.keycloak.models.utils.FormMessage;
     import org.keycloak.services.messages.Messages;
     import org.keycloak.services.validation.Validation;
    +import org.keycloak.sessions.AuthenticationSessionModel;
     import org.keycloak.utils.CredentialHelper;
     
     import jakarta.ws.rs.core.MultivaluedMap;
    @@ -159,6 +160,11 @@ public String getId() {
             return UserModel.RequiredAction.CONFIGURE_TOTP.name();
         }
     
    +    @Override
    +    public String getCredentialType(KeycloakSession session, AuthenticationSessionModel authenticationSession) {
    +        return OTPCredentialModel.TYPE;
    +    }
    +
         @Override
         public boolean isOneTimeAction() {
             return true;
    
  • services/src/main/java/org/keycloak/authentication/requiredactions/util/CredentialDeleteHelper.java+111 0 added
    @@ -0,0 +1,111 @@
    +/*
    + * Copyright 2024 Red Hat, Inc. and/or its affiliates
    + *  and other contributors as indicated by the @author tags.
    + *
    + *  Licensed under the Apache License, Version 2.0 (the "License");
    + *  you may not use this file except in compliance with the License.
    + *  You may obtain a copy of the License at
    + *
    + *  http://www.apache.org/licenses/LICENSE-2.0
    + *
    + *  Unless required by applicable law or agreed to in writing, software
    + *  distributed under the License is distributed on an "AS IS" BASIS,
    + *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + *
    + *  See the License for the specific language governing permissions and
    + *  limitations under the License.
    + *
    + */
    +
    +package org.keycloak.authentication.requiredactions.util;
    +
    +import java.util.Map;
    +import java.util.function.Supplier;
    +
    +import jakarta.ws.rs.BadRequestException;
    +import jakarta.ws.rs.ForbiddenException;
    +import jakarta.ws.rs.NotFoundException;
    +import org.jboss.logging.Logger;
    +import org.keycloak.authentication.AuthenticatorUtil;
    +import org.keycloak.authentication.authenticators.util.LoAUtil;
    +import org.keycloak.credential.CredentialModel;
    +import org.keycloak.credential.CredentialProvider;
    +import org.keycloak.credential.CredentialTypeMetadata;
    +import org.keycloak.credential.CredentialTypeMetadataContext;
    +import org.keycloak.models.KeycloakSession;
    +import org.keycloak.models.RealmModel;
    +import org.keycloak.models.UserModel;
    +
    +import static org.keycloak.models.Constants.NO_LOA;
    +
    +/**
    + * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
    + */
    +public class CredentialDeleteHelper {
    +
    +    private static final Logger logger = Logger.getLogger(CredentialDeleteHelper.class);
    +
    +    /**
    +     * Removing credential of given ID of specified user. It does the necessary validation to validate if specified credential can be removed.
    +     * In case of step-up authentication enabled, it verifies if user authenticated with corresponding level in order to be able to remove this credential.
    +     *
    +     * For instance removing 2nd-factor credential require authentication with 2nd-factor as well for security reasons.
    +     *
    +     * @param session
    +     * @param user
    +     * @param credentialId
    +     * @param currentLoAProvider supplier of current authenticated level. Can be retrieved for instance from session or from the token
    +     * @return removed credential. It can return null if credential was not found or if it was legacy format of federated credential ID
    +     */
    +    public static CredentialModel removeCredential(KeycloakSession session, UserModel user, String credentialId, Supplier<Integer> currentLoAProvider) {
    +        CredentialModel credential = user.credentialManager().getStoredCredentialById(credentialId);
    +        if (credential == null) {
    +            // Backwards compatibility with account console 1 - When stored credential is not found, it may be federated credential.
    +            // In this case, it's ID needs to be something like "otp-id", which is returned by account REST GET endpoint as a placeholder
    +            // for federated credentials (See CredentialHelper.createUserStorageCredentialRepresentation )
    +            if (credentialId.endsWith("-id")) {
    +                String credentialType = credentialId.substring(0, credentialId.length() - 3);
    +                checkIfCanBeRemoved(session, user, credentialType, currentLoAProvider);
    +                user.credentialManager().disableCredentialType(credentialType);
    +                return null;
    +            }
    +            throw new NotFoundException("Credential not found");
    +        }
    +        checkIfCanBeRemoved(session, user, credential.getType(), currentLoAProvider);
    +        user.credentialManager().removeStoredCredentialById(credentialId);
    +        return credential;
    +    }
    +
    +    private static void checkIfCanBeRemoved(KeycloakSession session, UserModel user, String credentialType, Supplier<Integer> currentLoAProvider) {
    +        CredentialProvider credentialProvider = AuthenticatorUtil.getCredentialProviders(session)
    +                .filter(credentialProvider1 -> credentialType.equals(credentialProvider1.getType()))
    +                .findAny().orElse(null);
    +        if (credentialProvider == null) {
    +            logger.warnf("Credential provider %s not found", credentialType);
    +            throw new NotFoundException("Credential provider not found");
    +        }
    +        CredentialTypeMetadataContext ctx = CredentialTypeMetadataContext.builder().user(user).build(session);
    +        CredentialTypeMetadata metadata = credentialProvider.getCredentialTypeMetadata(ctx);
    +        if (!metadata.isRemoveable()) {
    +            logger.warnf("Credential type %s cannot be removed", credentialType);
    +            throw new BadRequestException("Credential type cannot be removed");
    +        }
    +
    +        // Check if current accessToken has permission to remove credential in case of step-up authentication was used
    +        checkAuthenticatedLoASufficientForCredentialRemove(session, credentialType, currentLoAProvider);
    +    }
    +
    +    private static void checkAuthenticatedLoASufficientForCredentialRemove(KeycloakSession session, String credentialType, Supplier<Integer> currentLoAProvider) {
    +        int requestedLoaForCredentialRemove = getRequestedLoaForCredential(session, session.getContext().getRealm(), credentialType);
    +
    +        int currentAuthenticatedLevel = currentLoAProvider.get();
    +        if (currentAuthenticatedLevel < requestedLoaForCredentialRemove) {
    +            throw new ForbiddenException("Insufficient level of authentication for removing credential of type '" + credentialType + "'.");
    +        }
    +    }
    +
    +    private static int getRequestedLoaForCredential(KeycloakSession session, RealmModel realm, String credentialType) {
    +        Map<String, Integer> credentialTypesToLoa = LoAUtil.getCredentialTypesToLoAMap(session, realm, realm.getBrowserFlow());
    +        return credentialTypesToLoa.getOrDefault(credentialType, NO_LOA);
    +    }
    +}
    
  • services/src/main/java/org/keycloak/authentication/requiredactions/WebAuthnRegister.java+6 0 modified
    @@ -54,6 +54,7 @@
     import org.keycloak.models.UserModel;
     import org.keycloak.models.WebAuthnPolicy;
     import org.keycloak.models.credential.WebAuthnCredentialModel;
    +import org.keycloak.sessions.AuthenticationSessionModel;
     import org.keycloak.utils.StringUtil;
     
     import com.webauthn4j.converter.util.ObjectConverter;
    @@ -170,6 +171,11 @@ protected WebAuthnPolicy getWebAuthnPolicy(RequiredActionContext context) {
             return context.getRealm().getWebAuthnPolicy();
         }
     
    +    @Override
    +    public String getCredentialType(KeycloakSession session, AuthenticationSessionModel authenticationSession) {
    +        return getCredentialType();
    +    }
    +
         protected String getCredentialType() {
             return WebAuthnCredentialModel.TYPE_TWOFACTOR;
         }
    
  • services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java+11 2 modified
    @@ -64,6 +64,7 @@
     import org.keycloak.services.resources.RealmsResource;
     import org.keycloak.sessions.AuthenticationSessionModel;
     import org.keycloak.util.JsonSerialization;
    +import org.keycloak.util.TokenUtil;
     import org.keycloak.vault.VaultStringSecret;
     
     import jakarta.ws.rs.GET;
    @@ -79,6 +80,7 @@
     import java.io.IOException;
     import java.nio.charset.StandardCharsets;
     import java.util.ArrayList;
    +import java.util.Arrays;
     import java.util.List;
     import java.util.Objects;
     
    @@ -99,6 +101,7 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
         public static final String EXCHANGE_PROVIDER = "EXCHANGE_PROVIDER";
         public static final String VALIDATED_ACCESS_TOKEN = "VALIDATED_ACCESS_TOKEN";
         private static final String BROKER_NONCE_PARAM = "BROKER_NONCE";
    +    private static final List<String> SUPPORTED_TOKEN_TYPES = Arrays.asList(TokenUtil.TOKEN_TYPE_ID, TokenUtil.TOKEN_TYPE_BEARER, TokenUtil.TOKEN_TYPE_JWT_ACCESS_TOKEN, TokenUtil.TOKEN_TYPE_JWT_ACCESS_TOKEN_PREFIXED);
     
         public OIDCIdentityProvider(KeycloakSession session, OIDCIdentityProviderConfig config) {
             super(session, config);
    @@ -852,7 +855,7 @@ final protected BrokeredIdentityContext validateJwt(EventBuilder event, String s
                 throw new ErrorResponseException(Errors.INVALID_CONFIG, "Invalid server config", Response.Status.BAD_REQUEST);
             }
     
    -        JsonWebToken parsedToken = null;
    +        JsonWebToken parsedToken;
             try {
                 parsedToken = validateToken(subjectToken, true);
             } catch (IdentityBrokerException e) {
    @@ -863,7 +866,9 @@ final protected BrokeredIdentityContext validateJwt(EventBuilder event, String s
             }
     
             try {
    -
    +            if (!isTokenTypeSupported(parsedToken)) {
    +                throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "token type not supported", Response.Status.BAD_REQUEST);
    +            }
                 boolean idTokenType = OAuth2Constants.ID_TOKEN_TYPE.equals(subjectTokenType);
                 BrokeredIdentityContext context = extractIdentity(null, idTokenType ? null : subjectToken, parsedToken);
                 if (context == null) {
    @@ -889,6 +894,10 @@ final protected BrokeredIdentityContext validateJwt(EventBuilder event, String s
     
         }
     
    +    protected static boolean isTokenTypeSupported(JsonWebToken parsedToken) {
    +        return SUPPORTED_TOKEN_TYPES.contains(parsedToken.getType());
    +    }
    +
         @Override
         protected BrokeredIdentityContext exchangeExternalImpl(EventBuilder event, MultivaluedMap<String, String> params) {
             if (!supportsExternalExchange()) return null;
    
  • services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java+11 1 modified
    @@ -382,7 +382,17 @@ private Response buildForgotCredential() {
     
         public static void performActionOnParameters(AuthorizationEndpointRequest request, BiConsumer<String, String> paramAction) {
             paramAction.accept(AdapterConstants.KC_IDP_HINT, request.getIdpHint());
    -        paramAction.accept(Constants.KC_ACTION, request.getAction());
    +
    +        String kcAction = request.getAction();
    +        String kcActionParameter = null;
    +        if (kcAction != null && kcAction.contains(":")) {
    +            String[] splits = kcAction.split(":");
    +            kcAction = splits[0];
    +            kcActionParameter = splits[1];
    +        }
    +        paramAction.accept(Constants.KC_ACTION, kcAction);
    +        paramAction.accept(Constants.KC_ACTION_PARAMETER, kcActionParameter);
    +
             paramAction.accept(OAuth2Constants.DISPLAY, request.getDisplay());
             paramAction.accept(OIDCLoginProtocol.ACR_PARAM, request.getAcr());
             paramAction.accept(OIDCLoginProtocol.CLAIMS_PARAM, request.getClaims());
    
  • services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java+1 1 modified
    @@ -577,7 +577,7 @@ public static ClientSessionContext attachAuthenticationSession(KeycloakSession s
                 userSession.setNote(entry.getKey(), entry.getValue());
             }
     
    -        clientSession.setNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(new AcrStore(authSession).getLevelOfAuthenticationFromCurrentAuthentication()));
    +        clientSession.setNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(new AcrStore(session, authSession).getLevelOfAuthenticationFromCurrentAuthentication()));
             clientSession.setTimestamp(userSession.getLastSessionRefresh());
     
             // Remove authentication session now
    
  • services/src/main/java/org/keycloak/protocol/oidc/utils/AcrUtils.java+2 2 modified
    @@ -102,7 +102,7 @@ private static Map<String, Integer> getAcrLoaMapForClientOnly(ClientModel client
             try {
                 return JsonSerialization.readValue(map, new TypeReference<Map<String, Integer>>() {});
             } catch (IOException e) {
    -            LOGGER.warnf("Invalid client configuration (ACR-LOA map) for client '%s'", client.getClientId());
    +            LOGGER.warnf("Invalid client configuration (ACR-LOA map) for client '%s'. Error details: %s", client.getClientId(), e.getMessage());
                 return Collections.emptyMap();
             }
         }
    @@ -119,7 +119,7 @@ public static Map<String, Integer> getAcrLoaMap(RealmModel realm) {
             try {
                 return JsonSerialization.readValue(map, new TypeReference<Map<String, Integer>>() {});
             } catch (IOException e) {
    -            LOGGER.warn("Invalid realm configuration (ACR-LOA map)");
    +            LOGGER.warnf("Invalid realm configuration (ACR-LOA map). Details: %s", e.getMessage());
                 return Collections.emptyMap();
             }
         }
    
  • services/src/main/java/org/keycloak/services/messages/Messages.java+1 0 modified
    @@ -25,6 +25,7 @@ public class Messages {
         public static final String LOGIN_TIMEOUT = "loginTimeout";
     
         public static final String REAUTHENTICATE = "reauthenticate";
    +    public static final String AUTHENTICATE_STRONG = "authenticateStrong";
     
         public static final String INVALID_USER = "invalidUserMessage";
     
    
  • services/src/main/java/org/keycloak/services/resources/account/AccountCredentialResource.java+32 37 modified
    @@ -2,22 +2,26 @@
     
     import com.fasterxml.jackson.annotation.JsonIgnore;
     import org.jboss.resteasy.annotations.cache.NoCache;
    +import jakarta.ws.rs.ForbiddenException;
    +import org.jboss.logging.Logger;
     import org.keycloak.authentication.Authenticator;
     import org.keycloak.authentication.AuthenticatorFactory;
    -import org.keycloak.common.util.reflections.Types;
    +import org.keycloak.authentication.AuthenticatorUtil;
    +import org.keycloak.authentication.requiredactions.util.CredentialDeleteHelper;
     import org.keycloak.credential.CredentialMetadata;
     import org.keycloak.credential.CredentialModel;
     import org.keycloak.credential.CredentialProvider;
    -import org.keycloak.credential.CredentialProviderFactory;
     import org.keycloak.credential.CredentialTypeMetadata;
     import org.keycloak.credential.CredentialTypeMetadataContext;
     import org.keycloak.models.AccountRoles;
     import org.keycloak.models.AuthenticationExecutionModel;
     import org.keycloak.models.AuthenticationFlowModel;
    +import org.keycloak.models.ClientModel;
     import org.keycloak.models.KeycloakSession;
     import org.keycloak.models.RealmModel;
     import org.keycloak.models.UserModel;
     import org.keycloak.models.utils.ModelToRepresentation;
    +import org.keycloak.protocol.oidc.utils.AcrUtils;
     import org.keycloak.representations.account.CredentialMetadataRepresentation;
     import org.keycloak.representations.idm.CredentialRepresentation;
     import org.keycloak.services.ErrorResponse;
    @@ -26,7 +30,6 @@
     import org.keycloak.util.JsonSerialization;
     import org.keycloak.utils.MediaType;
     
    -import jakarta.ws.rs.BadRequestException;
     import jakarta.ws.rs.Consumes;
     import jakarta.ws.rs.DELETE;
     import jakarta.ws.rs.GET;
    @@ -41,6 +44,7 @@
     import java.util.Collections;
     import java.util.Comparator;
     import java.util.List;
    +import java.util.Map;
     import java.util.Objects;
     import java.util.Set;
     import java.util.function.Function;
    @@ -56,6 +60,8 @@ public class AccountCredentialResource {
         public static final String TYPE = "type";
         public static final String USER_CREDENTIALS = "user-credentials";
     
    +    private static final Logger logger = Logger.getLogger(AccountCredentialResource.class);
    +
     
         private final KeycloakSession session;
         private final UserModel user;
    @@ -215,20 +221,14 @@ public Stream<CredentialContainer> credentialTypes(@QueryParam(TYPE) String type
                 return new CredentialContainer(metadata, userCredentialMetadataModels);
             };
     
    -        return getCredentialProviders()
    +        return AuthenticatorUtil.getCredentialProviders(session)
                     .filter(p -> type == null || Objects.equals(p.getType(), type))
                     .filter(p -> enabledCredentialTypes.contains(p.getType()))
                     .map(toCredentialContainer)
                     .filter(Objects::nonNull)
                     .sorted(Comparator.comparing(CredentialContainer::getMetadata));
         }
     
    -    private Stream<CredentialProvider> getCredentialProviders() {
    -        return session.getKeycloakSessionFactory().getProviderFactoriesStream(CredentialProvider.class)
    -                .filter(f -> Types.supports(CredentialProvider.class, f, CredentialProviderFactory.class))
    -                .map(f -> session.getProvider(CredentialProvider.class, f.getId()));
    -    }
    -
         // Going through all authentication flows and their authentication executions to see if there is any authenticator of the corresponding
         // credential type.
         private Set<String> getEnabledCredentialTypes() {
    @@ -261,47 +261,42 @@ private boolean isFlowEffectivelyDisabled(AuthenticationFlowModel flow) {
             return false;
         }
     
    -    private void checkIfCanBeRemoved(String credentialType) {
    -        Set<String> enabledCredentialTypes = getEnabledCredentialTypes();
    -        CredentialProvider credentialProvider = getCredentialProviders()
    -                .filter(p -> credentialType.equals(p.getType()) && enabledCredentialTypes.contains(p.getType()))
    -                .findAny().orElse(null);
    -        if (credentialProvider == null) {
    -            throw new NotFoundException("Credential provider " + credentialType + " not found");
    +    private Integer getCurrentAuthenticatedLevel() {
    +        ClientModel client = realm.getClientByClientId(auth.getToken().getIssuedFor());
    +        Map<String, Integer> acrLoaMap = AcrUtils.getAcrLoaMap(client);
    +        String tokenAcr = auth.getToken().getAcr();
    +        if (tokenAcr == null) {
    +            logger.warnf("Not able to remove credential of user '%s' as no acr claim on the token", user.getUsername());
    +            throw new ForbiddenException("No LoA on the token");
             }
    -        CredentialTypeMetadataContext ctx = CredentialTypeMetadataContext.builder().user(user).build(session);
    -        CredentialTypeMetadata metadata = credentialProvider.getCredentialTypeMetadata(ctx);
    -        if (!metadata.isRemoveable()) {
    -            throw new BadRequestException("Credential type " + credentialType + " cannot be removed");
    +        Integer currentAuthenticatedLevel = acrLoaMap.get(tokenAcr);
    +        if (currentAuthenticatedLevel != null) {
    +            return currentAuthenticatedLevel;
    +        } else {
    +            try {
    +                return Integer.parseInt(tokenAcr);
    +            } catch (NumberFormatException nfe) {
    +                logger.warnf("Token acr '%s' not found in acrLoaMap of client '%s' or realm '%s'. Not able to remove credential of user '%s'",
    +                        tokenAcr, client.getClientId(), realm.getName(), user.getUsername());
    +                throw new ForbiddenException("Unsupported acr on the token");
    +            }
             }
         }
     
         /**
          * Remove a credential of current user
          *
          * @param credentialId ID of the credential, which will be removed
    +     * @deprecated It is recommended to delete credentials with the use of "delete_credential" kc_action.
    +     * Action can be used for instance by adding parameter like "kc_action=delete_credential:123" to the login URL where 123 is ID of the credential to delete.
          */
         @Path("{credentialId}")
         @DELETE
         @NoCache
    +    @Deprecated
         public void removeCredential(final @PathParam("credentialId") String credentialId) {
             auth.require(AccountRoles.MANAGE_ACCOUNT);
    -        CredentialModel credential = user.credentialManager().getStoredCredentialById(credentialId);
    -        if (credential == null) {
    -            // Backwards compatibility with account console 1 - When stored credential is not found, it may be federated credential.
    -            // In this case, it's ID needs to be something like "otp-id", which is returned by account REST GET endpoint as a placeholder
    -            // for federated credentials (See CredentialHelper.createUserStorageCredentialRepresentation )
    -            if (credentialId.endsWith("-id")) {
    -                String credentialType = credentialId.substring(0, credentialId.length() - 3);
    -                checkIfCanBeRemoved(credentialType);
    -                user.credentialManager().disableCredentialType(credentialType);
    -                return;
    -            }
    -
    -            throw new NotFoundException("Credential not found");
    -        }
    -        checkIfCanBeRemoved(credential.getType());
    -        user.credentialManager().removeStoredCredentialById(credentialId);
    +        CredentialDeleteHelper.removeCredential(session, user, credentialId, this::getCurrentAuthenticatedLevel);
         }
     
     
    
  • services/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory+1 0 modified
    @@ -24,6 +24,7 @@ org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory
     org.keycloak.authentication.requiredactions.WebAuthnPasswordlessRegisterFactory
     org.keycloak.authentication.requiredactions.UpdateUserLocaleAction
     org.keycloak.authentication.requiredactions.DeleteAccount
    +org.keycloak.authentication.requiredactions.DeleteCredentialAction
     org.keycloak.authentication.requiredactions.VerifyUserProfile
     org.keycloak.authentication.requiredactions.RecoveryAuthnCodesAction
     org.keycloak.authentication.requiredactions.UpdateEmail
    
  • testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/authentication/CustomAuthenticationFlowCallback.java+2 1 modified
    @@ -21,6 +21,7 @@
     import org.keycloak.authentication.AuthenticationFlowContext;
     import org.keycloak.authentication.AuthenticationFlowError;
     import org.keycloak.authentication.AuthenticationFlowException;
    +import org.keycloak.models.AuthenticationFlowModel;
     import org.keycloak.models.KeycloakSession;
     import org.keycloak.models.RealmModel;
     import org.keycloak.models.UserModel;
    @@ -33,7 +34,7 @@ public class CustomAuthenticationFlowCallback implements AuthenticationFlowCallb
         public static final String EXPECTED_ERROR_MESSAGE = "Custom Authentication Flow Callback message";
     
         @Override
    -    public void onTopFlowSuccess() {
    +    public void onTopFlowSuccess(AuthenticationFlowModel topFlow) {
             throw new AuthenticationFlowException(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR, "detail", EXPECTED_ERROR_MESSAGE);
         }
     
    
  • testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProviderFactory.java+1 1 modified
    @@ -45,7 +45,7 @@
     public class TestApplicationResourceProviderFactory implements RealmResourceProviderFactory {
     
         private BlockingQueue<LogoutAction> adminLogoutActions = new LinkedBlockingDeque<>();
    -    private BlockingQueue<LogoutToken> backChannelLogoutTokens = new LinkedBlockingDeque<>();
    +    private BlockingQueue<String> backChannelLogoutTokens = new LinkedBlockingDeque<>();
         private BlockingQueue<LogoutToken> frontChannelLogoutTokens = new LinkedBlockingDeque<>();
         private BlockingQueue<PushNotBeforeAction> pushNotBeforeActions = new LinkedBlockingDeque<>();
         private BlockingQueue<TestAvailabilityAction> testAvailabilityActions = new LinkedBlockingDeque<>();
    
  • testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProvider.java+12 5 modified
    @@ -59,7 +59,7 @@ public class TestApplicationResourceProvider implements RealmResourceProvider {
     
         private final BlockingQueue<LogoutAction> adminLogoutActions;
         private final BlockingQueue<LogoutToken> frontChannelLogoutTokens;
    -    private final BlockingQueue<LogoutToken> backChannelLogoutTokens;
    +    private final BlockingQueue<String> backChannelLogoutTokens;
         private final BlockingQueue<PushNotBeforeAction> adminPushNotBeforeActions;
         private final BlockingQueue<TestAvailabilityAction> adminTestAvailabilityAction;
         private final TestApplicationResourceProviderFactory.OIDCClientData oidcClientData;
    @@ -71,7 +71,7 @@ public class TestApplicationResourceProvider implements RealmResourceProvider {
         private final HttpRequest request;
     
         public TestApplicationResourceProvider(KeycloakSession session, BlockingQueue<LogoutAction> adminLogoutActions,
    -            BlockingQueue<LogoutToken> backChannelLogoutTokens,
    +            BlockingQueue<String> backChannelLogoutTokens,
                 BlockingQueue<LogoutToken> frontChannelLogoutTokens,
                 BlockingQueue<PushNotBeforeAction> adminPushNotBeforeActions,
                 BlockingQueue<TestAvailabilityAction> adminTestAvailabilityAction,
    @@ -102,8 +102,8 @@ public void adminLogout(String data) throws JWSInputException {
         @POST
         @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
         @Path("/admin/backchannelLogout")
    -    public void backchannelLogout() throws JWSInputException {
    -        backChannelLogoutTokens.add(new JWSInput(request.getDecodedFormParameters().getFirst(OAuth2Constants.LOGOUT_TOKEN)).readJsonContent(LogoutToken.class));
    +    public void backchannelLogout() {
    +        backChannelLogoutTokens.add(request.getDecodedFormParameters().getFirst(OAuth2Constants.LOGOUT_TOKEN));
         }
     
         @GET
    @@ -139,7 +139,14 @@ public LogoutAction getAdminLogoutAction() throws InterruptedException {
         @GET
         @Produces(MediaType.APPLICATION_JSON)
         @Path("/poll-backchannel-logout")
    -    public LogoutToken getBackChannelLogoutAction() throws InterruptedException {
    +    public LogoutToken getBackChannelLogoutAction() throws InterruptedException, JWSInputException {
    +        return new JWSInput(backChannelLogoutTokens.poll(20, TimeUnit.SECONDS)).readJsonContent(LogoutToken.class);
    +    }
    +
    +    @GET
    +    @Produces(MediaType.APPLICATION_JSON)
    +    @Path("/poll-backchannel-raw-logout")
    +    public String getBackChanneRawlLogoutAction() throws InterruptedException {
             return backChannelLogoutTokens.poll(20, TimeUnit.SECONDS);
         }
     
    
  • testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/account/AccountRestClient.java+172 0 added
    @@ -0,0 +1,172 @@
    +/*
    + * Copyright 2024 Red Hat, Inc. and/or its affiliates
    + *  and other contributors as indicated by the @author tags.
    + *
    + *  Licensed under the Apache License, Version 2.0 (the "License");
    + *  you may not use this file except in compliance with the License.
    + *  You may obtain a copy of the License at
    + *
    + *  http://www.apache.org/licenses/LICENSE-2.0
    + *
    + *  Unless required by applicable law or agreed to in writing, software
    + *  distributed under the License is distributed on an "AS IS" BASIS,
    + *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + *
    + *  See the License for the specific language governing permissions and
    + *  limitations under the License.
    + *
    + */
    +
    +package org.keycloak.testsuite.account;
    +
    +import java.io.IOException;
    +import java.util.List;
    +import java.util.function.Supplier;
    +
    +import com.fasterxml.jackson.core.type.TypeReference;
    +import org.apache.http.impl.client.CloseableHttpClient;
    +import org.apache.http.impl.client.HttpClientBuilder;
    +import org.keycloak.broker.provider.util.SimpleHttp;
    +import org.keycloak.representations.account.CredentialMetadataRepresentation;
    +import org.keycloak.representations.idm.CredentialRepresentation;
    +import org.keycloak.services.resources.account.AccountCredentialResource;
    +import org.keycloak.testsuite.arquillian.SuiteContext;
    +import org.keycloak.testsuite.util.TokenUtil;
    +
    +/**
    + * Helper client for account REST API
    + *
    + * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
    + */
    +public class AccountRestClient implements AutoCloseable {
    +
    +    private final SuiteContext suiteContext;
    +    private final CloseableHttpClient httpClient;
    +    private final Supplier<String> tokenProvider;
    +    private final String apiVersion;
    +    private final String realmName;
    +
    +    private AccountRestClient(SuiteContext suiteContext, CloseableHttpClient httpClient, Supplier<String> tokenProvider, String apiVersion, String realmName) {
    +        this.suiteContext = suiteContext;
    +        this.httpClient = httpClient;
    +        this.tokenProvider = tokenProvider;
    +        this.apiVersion = apiVersion;
    +        this.realmName = realmName;
    +    }
    +
    +    public List<AccountCredentialResource.CredentialContainer> getCredentials() {
    +        try {
    +            return SimpleHttp.doGet(getAccountUrl("credentials"), httpClient)
    +                    .auth(tokenProvider.get()).asJson(new TypeReference<List<AccountCredentialResource.CredentialContainer>>() {
    +                    });
    +        } catch (IOException ioe) {
    +            throw new RuntimeException("Failed to get credentials", ioe);
    +        }
    +    }
    +
    +    public CredentialRepresentation getCredentialByUserLabel(String userLabel) {
    +        return getCredentials().stream()
    +                .flatMap(credentialContainer -> credentialContainer.getUserCredentialMetadatas().stream())
    +                .map(CredentialMetadataRepresentation::getCredential)
    +                .filter(credentialRep -> userLabel.equals(credentialRep.getUserLabel()))
    +                .findFirst()
    +                .orElse(null);
    +    }
    +
    +    public SimpleHttp.Response removeCredential(String credentialId) {
    +        try {
    +            return SimpleHttp
    +                    .doDelete(getAccountUrl("credentials/" + credentialId), httpClient)
    +                    .acceptJson()
    +                    .auth(tokenProvider.get())
    +                    .asResponse();
    +        } catch (IOException ioe) {
    +            throw new RuntimeException("Failed to delete credential", ioe);
    +        }
    +    }
    +
    +    // TODO: Other objects...
    +
    +
    +    @Override
    +    public void close() {
    +        if (httpClient != null) {
    +            try {
    +                httpClient.close();
    +            } catch (IOException ioe) {
    +                throw new RuntimeException("Error closing httpClient", ioe);
    +            }
    +        }
    +    }
    +
    +
    +    private String getAccountUrl(String resource) {
    +        String url = suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/" + realmName + "/account";
    +        if (apiVersion != null) {
    +            url += "/" + apiVersion;
    +        }
    +        if (resource != null) {
    +            url += "/" + resource;
    +        }
    +        return url;
    +    }
    +
    +    public static AccountRestClientBuilder builder(SuiteContext suiteContext) {
    +        return new AccountRestClientBuilder(suiteContext);
    +    }
    +
    +
    +    public static class AccountRestClientBuilder {
    +
    +        private SuiteContext suiteContext;
    +        private CloseableHttpClient httpClient;
    +        private Supplier<String> tokenProvider;
    +        private String apiVersion;
    +        private String realmName;
    +
    +        private AccountRestClientBuilder(SuiteContext suiteContext) {
    +            this.suiteContext = suiteContext;
    +        }
    +
    +        public AccountRestClientBuilder httpClient(CloseableHttpClient httpClient) {
    +            this.httpClient = httpClient;
    +            return this;
    +        }
    +
    +        public AccountRestClientBuilder tokenUtil(TokenUtil tokenUtil) {
    +            this.tokenProvider = tokenUtil::getToken;
    +            return this;
    +        }
    +
    +        public AccountRestClientBuilder accessToken(String accessToken) {
    +            this.tokenProvider = () -> accessToken;
    +            return this;
    +        }
    +
    +        public AccountRestClientBuilder apiVersion(String apiVersion) {
    +            this.apiVersion = apiVersion;
    +            return this;
    +        }
    +
    +        public AccountRestClientBuilder realmName(String realmName) {
    +            this.realmName = realmName;
    +            return this;
    +        }
    +
    +        public AccountRestClient build() {
    +            if (httpClient == null) {
    +                httpClient = HttpClientBuilder.create().build();
    +            }
    +            if (realmName == null) {
    +                realmName = "test";
    +            }
    +            if (tokenProvider == null) {
    +                TokenUtil tokenUtil = new TokenUtil();
    +                tokenProvider = tokenUtil::getToken;
    +            }
    +            return new AccountRestClient(suiteContext, httpClient, tokenProvider, apiVersion, realmName);
    +        }
    +
    +    }
    +
    +}
    
  • testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestApplicationResource.java+5 0 modified
    @@ -45,6 +45,11 @@ public interface TestApplicationResource {
         @Path("/poll-backchannel-logout")
         LogoutToken getBackChannelLogoutToken();
     
    +    @GET
    +    @Produces(MediaType.APPLICATION_JSON)
    +    @Path("/poll-backchannel-raw-logout")
    +    String getBackChannelRawLogoutToken();
    +
         @GET
         @Produces(MediaType.APPLICATION_JSON)
         @Path("/poll-frontchannel-logout")
    
  • testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/DeleteCredentialPage.java+59 0 added
    @@ -0,0 +1,59 @@
    +/*
    + * Copyright 2024 Red Hat, Inc. and/or its affiliates
    + *  and other contributors as indicated by the @author tags.
    + *
    + *  Licensed under the Apache License, Version 2.0 (the "License");
    + *  you may not use this file except in compliance with the License.
    + *  You may obtain a copy of the License at
    + *
    + *  http://www.apache.org/licenses/LICENSE-2.0
    + *
    + *  Unless required by applicable law or agreed to in writing, software
    + *  distributed under the License is distributed on an "AS IS" BASIS,
    + *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + *
    + *  See the License for the specific language governing permissions and
    + *  limitations under the License.
    + *
    + */
    +
    +package org.keycloak.testsuite.pages;
    +
    +import org.junit.Assert;
    +import org.openqa.selenium.WebElement;
    +import org.openqa.selenium.support.FindBy;
    +
    +/**
    + * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
    + */
    +public class DeleteCredentialPage extends AbstractPage {
    +
    +    @FindBy(id = "kc-accept")
    +    private WebElement submitButton;
    +
    +    @FindBy(id = "kc-decline")
    +    private WebElement cancelButton;
    +
    +    @FindBy(id = "kc-delete-text")
    +    private WebElement message;
    +
    +    public boolean isCurrent() {
    +        return PageUtils.getPageTitle(driver).startsWith("Delete ");
    +    }
    +
    +    public void confirm() {
    +        submitButton.click();
    +    }
    +    public void cancel() {
    +        cancelButton.click();
    +    }
    +
    +    public void assertCredentialInMessage(String expectedLabel) {
    +        Assert.assertEquals("Do you want to delete " + expectedLabel + "?", message.getText());
    +    }
    +
    +    @Override
    +    public void open() {
    +        throw new UnsupportedOperationException();
    +    }
    +}
    
  • testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java+1 1 modified
    @@ -920,7 +920,7 @@ public void testCredentialsForUserWithoutPassword() throws IOException {
                     .auth(tokenUtil.getToken())
                     .asResponse()) {
                 assertEquals(400, response.getStatus());
    -            Assert.assertEquals("Credential type password cannot be removed", response.asJson(OAuth2ErrorRepresentation.class).getError());
    +            Assert.assertEquals("Credential type cannot be removed", response.asJson(OAuth2ErrorRepresentation.class).getError());
             }
     
             // Remove password from the user now
    
  • testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/AppInitiatedActionDeleteCredentialTest.java+266 0 added
    @@ -0,0 +1,266 @@
    +/*
    + * Copyright 2024 Red Hat, Inc. and/or its affiliates
    + *  and other contributors as indicated by the @author tags.
    + *
    + *  Licensed under the Apache License, Version 2.0 (the "License");
    + *  you may not use this file except in compliance with the License.
    + *  You may obtain a copy of the License at
    + *
    + *  http://www.apache.org/licenses/LICENSE-2.0
    + *
    + *  Unless required by applicable law or agreed to in writing, software
    + *  distributed under the License is distributed on an "AS IS" BASIS,
    + *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + *
    + *  See the License for the specific language governing permissions and
    + *  limitations under the License.
    + *
    + */
    +
    +package org.keycloak.testsuite.actions;
    +
    +import java.util.List;
    +
    +import jakarta.ws.rs.core.Response;
    +import org.jboss.arquillian.graphene.page.Page;
    +import org.junit.Assert;
    +import org.junit.Before;
    +import org.junit.Test;
    +import org.keycloak.authentication.requiredactions.DeleteCredentialAction;
    +import org.keycloak.events.Details;
    +import org.keycloak.events.Errors;
    +import org.keycloak.events.EventType;
    +import org.keycloak.models.credential.OTPCredentialModel;
    +import org.keycloak.models.credential.PasswordCredentialModel;
    +import org.keycloak.models.utils.TimeBasedOTP;
    +import org.keycloak.representations.idm.CredentialRepresentation;
    +import org.keycloak.representations.idm.RealmRepresentation;
    +import org.keycloak.representations.idm.UserRepresentation;
    +import org.keycloak.testsuite.admin.ApiUtil;
    +import org.keycloak.testsuite.pages.DeleteCredentialPage;
    +import org.keycloak.testsuite.pages.ErrorPage;
    +import org.keycloak.testsuite.pages.LoginConfigTotpPage;
    +import org.keycloak.testsuite.pages.LoginTotpPage;
    +import org.keycloak.testsuite.util.UserBuilder;
    +
    +/**
    + * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
    + */
    +public class AppInitiatedActionDeleteCredentialTest extends AbstractAppInitiatedActionTest {
    +
    +    @Override
    +    protected String getAiaAction() {
    +        return DeleteCredentialAction.PROVIDER_ID;
    +    }
    +
    +    @Override
    +    public void configureTestRealm(RealmRepresentation testRealm) {
    +        testRealm.setResetPasswordAllowed(Boolean.TRUE);
    +    }
    +
    +    @Page
    +    protected LoginTotpPage loginTotpPage;
    +
    +    @Page
    +    protected LoginConfigTotpPage totpPage;
    +
    +    @Page
    +    protected DeleteCredentialPage deleteCredentialPage;
    +
    +    @Page
    +    protected ErrorPage errorPage;
    +
    +    protected TimeBasedOTP totp = new TimeBasedOTP();
    +
    +    private String userId;
    +
    +    @Before
    +    public void beforeTest() {
    +        UserRepresentation user = UserBuilder.create()
    +                .username("john")
    +                .email("john@email.cz")
    +                .firstName("John")
    +                .lastName("Bar")
    +                .enabled(true)
    +                .password("password")
    +                .totpSecret("mySecret").build();
    +        Response response = testRealm().users().create(user);
    +        userId = ApiUtil.getCreatedId(response);
    +        response.close();
    +        getCleanup().addUserId(userId);
    +    }
    +
    +    @Test
    +    public void removeTotpSuccess() throws Exception {
    +        String credentialId = getCredentialIdByType(OTPCredentialModel.TYPE);
    +        oauth.kcAction(getKcActionParamForDeleteCredential(credentialId));
    +
    +        loginPasswordAndOtp();
    +
    +        deleteCredentialPage.assertCurrent();
    +        deleteCredentialPage.assertCredentialInMessage(OTPCredentialModel.TYPE);
    +
    +        deleteCredentialPage.confirm();
    +
    +        appPage.assertCurrent();
    +        assertKcActionStatus("success");
    +
    +        Assert.assertNull(getCredentialIdByType(OTPCredentialModel.TYPE));
    +
    +        events.expect(EventType.REMOVE_TOTP)
    +                .user(userId)
    +                .detail(Details.CREDENTIAL_TYPE, OTPCredentialModel.TYPE)
    +                .detail(Details.CREDENTIAL_ID, credentialId)
    +                .detail(Details.CUSTOM_REQUIRED_ACTION, DeleteCredentialAction.PROVIDER_ID)
    +                .assertEvent();
    +    }
    +
    +    @Test
    +    public void removeTotpCancel() throws Exception {
    +        String credentialId = getCredentialIdByType(OTPCredentialModel.TYPE);
    +
    +        loginPasswordAndOtp();
    +
    +        appPage.assertCurrent();
    +        events.clear();
    +
    +        oauth.kcAction(getKcActionParamForDeleteCredential(credentialId));
    +        oauth.openLoginForm();
    +
    +        // Cancel on the confirmation page
    +        deleteCredentialPage.assertCurrent();
    +        deleteCredentialPage.assertCredentialInMessage(OTPCredentialModel.TYPE);
    +        deleteCredentialPage.cancel();
    +
    +        appPage.assertCurrent();
    +
    +        Assert.assertNotNull(getCredentialIdByType(OTPCredentialModel.TYPE));
    +    }
    +
    +    @Test
    +    public void removePasswordShouldFail() throws Exception {
    +        String credentialId = getCredentialIdByType(PasswordCredentialModel.TYPE);
    +        loginPasswordAndOtp();
    +
    +        appPage.assertCurrent();
    +        events.clear();
    +
    +        oauth.kcAction(getKcActionParamForDeleteCredential(credentialId));
    +        oauth.openLoginForm();
    +
    +        // Cancel on the confirmation page
    +        deleteCredentialPage.assertCurrent();
    +        deleteCredentialPage.assertCredentialInMessage(PasswordCredentialModel.TYPE);
    +        deleteCredentialPage.confirm();
    +
    +        errorPage.assertCurrent();
    +
    +        events.expect(EventType.CUSTOM_REQUIRED_ACTION)
    +                .user(userId)
    +                .detail(Details.CREDENTIAL_TYPE, PasswordCredentialModel.TYPE)
    +                .detail(Details.CREDENTIAL_ID, credentialId)
    +                .detail(Details.CUSTOM_REQUIRED_ACTION, DeleteCredentialAction.PROVIDER_ID)
    +                .detail(Details.REASON, "Credential type cannot be removed")
    +                .error(Errors.DELETE_CREDENTIAL_FAILED)
    +                .assertEvent();
    +    }
    +
    +    @Test
    +    public void missingActionId() throws Exception {
    +        loginPasswordAndOtp();
    +
    +        appPage.assertCurrent();
    +        events.clear();
    +
    +        oauth.kcAction(DeleteCredentialAction.PROVIDER_ID);
    +        oauth.openLoginForm();
    +
    +        events.expect(EventType.CUSTOM_REQUIRED_ACTION)
    +                .user(userId)
    +                .error(Errors.MISSING_CREDENTIAL_ID);
    +
    +        // Redirected to the application. Action will be ignored
    +        appPage.assertCurrent();
    +    }
    +
    +    @Test
    +    public void incorrectId() throws Exception {
    +        loginPasswordAndOtp();
    +
    +        appPage.assertCurrent();
    +        events.clear();
    +
    +        oauth.kcAction(getKcActionParamForDeleteCredential("incorrect"));
    +        oauth.openLoginForm();
    +
    +        // Redirected to the application. Action will be ignored
    +        appPage.assertCurrent();
    +
    +        events.expect(EventType.CUSTOM_REQUIRED_ACTION)
    +                .user(userId)
    +                .detail(Details.CREDENTIAL_ID, "incorrect")
    +                .error(Errors.CREDENTIAL_NOT_FOUND);
    +    }
    +
    +    @Test
    +    public void requiredActionByAdmin() throws Exception {
    +        // Add required action by admin. It will be ignored as there is no credentialId
    +        UserRepresentation user = testRealm().users().get(userId).toRepresentation();
    +        user.setRequiredActions(List.of(DeleteCredentialAction.PROVIDER_ID));
    +        testRealm().users().get(userId).update(user);
    +
    +        loginPasswordAndOtp();
    +        appPage.assertCurrent();
    +
    +        events.expect(EventType.CUSTOM_REQUIRED_ACTION)
    +                .user(userId)
    +                .error(Errors.MISSING_CREDENTIAL_ID);
    +    }
    +
    +    @Test
    +    public void removeTotpCustomLabel() throws Exception {
    +        String credentialId = getCredentialIdByType(OTPCredentialModel.TYPE);
    +        testRealm().users().get(userId).setCredentialUserLabel(credentialId, "custom-otp-authenticator");
    +
    +        oauth.kcAction(getKcActionParamForDeleteCredential(credentialId));
    +        loginPasswordAndOtp();
    +
    +        deleteCredentialPage.assertCurrent();
    +        deleteCredentialPage.assertCredentialInMessage("custom-otp-authenticator");
    +
    +        deleteCredentialPage.confirm();
    +
    +        appPage.assertCurrent();
    +        assertKcActionStatus("success");
    +
    +        Assert.assertNull(getCredentialIdByType(OTPCredentialModel.TYPE));
    +
    +        events.expect(EventType.REMOVE_TOTP)
    +                .user(userId)
    +                .detail(Details.CREDENTIAL_TYPE, OTPCredentialModel.TYPE)
    +                .detail(Details.CREDENTIAL_ID, credentialId)
    +                .detail(Details.CUSTOM_REQUIRED_ACTION, DeleteCredentialAction.PROVIDER_ID)
    +                .assertEvent();
    +    }
    +
    +    private String getCredentialIdByType(String type) {
    +        List<CredentialRepresentation> credentials = testRealm().users().get(userId).credentials();
    +        return credentials.stream()
    +                .filter(credential -> type.equals(credential.getType()))
    +                .findFirst()
    +                .map(CredentialRepresentation::getId)
    +                .orElse(null);
    +    }
    +
    +    public static String getKcActionParamForDeleteCredential(String credentialId) {
    +        return DeleteCredentialAction.PROVIDER_ID + ":" + credentialId;
    +    }
    +
    +    private void loginPasswordAndOtp() {
    +        oauth.openLoginForm();
    +        loginPage.login("john", "password");
    +        loginTotpPage.assertCurrent();
    +        loginTotpPage.login(totp.generateTOTP("mySecret"));
    +    }
    +
    +}
    
  • testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/RequiredActionsTest.java+1 0 modified
    @@ -51,6 +51,7 @@ public void testRequiredActions() {
             addRequiredAction(expected, "UPDATE_PROFILE", "Update Profile", true, false, null);
             addRequiredAction(expected, "VERIFY_EMAIL", "Verify Email", true, false, null);
             addRequiredAction(expected, "delete_account", "Delete Account", false, false, null);
    +        addRequiredAction(expected, "delete_credential", "Delete Credential", 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);
    
  • testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerTokenExchangeTest.java+66 0 modified
    @@ -23,12 +23,15 @@
     import static org.keycloak.testsuite.broker.BrokerTestConstants.IDP_OIDC_ALIAS;
     import static org.keycloak.testsuite.util.ProtocolMapperUtil.createHardcodedClaim;
     
    +import java.util.concurrent.TimeUnit;
    +
     import jakarta.ws.rs.client.Client;
     import jakarta.ws.rs.client.Entity;
     import jakarta.ws.rs.client.WebTarget;
     import jakarta.ws.rs.core.Form;
     import jakarta.ws.rs.core.HttpHeaders;
     import jakarta.ws.rs.core.Response;
    +import jakarta.ws.rs.core.Response.Status;
     import org.junit.Test;
     import org.keycloak.OAuth2Constants;
     import org.keycloak.admin.client.resource.ClientResource;
    @@ -45,14 +48,17 @@
     import org.keycloak.models.IdentityProviderModel;
     import org.keycloak.models.KeycloakSession;
     import org.keycloak.models.RealmModel;
    +import org.keycloak.protocol.oidc.OIDCConfigAttributes;
     import org.keycloak.protocol.oidc.OIDCLoginProtocol;
     import org.keycloak.representations.idm.ClientRepresentation;
     import org.keycloak.representations.idm.IdentityProviderMapperRepresentation;
    +import org.keycloak.representations.idm.IdentityProviderRepresentation;
     import org.keycloak.representations.idm.UserRepresentation;
     import org.keycloak.representations.idm.authorization.ClientPolicyRepresentation;
     import org.keycloak.representations.idm.authorization.DecisionStrategy;
     import org.keycloak.services.resources.admin.permissions.AdminPermissionManagement;
     import org.keycloak.services.resources.admin.permissions.AdminPermissions;
    +import org.keycloak.testsuite.Assert;
     import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
     import org.keycloak.testsuite.arquillian.annotation.EnableFeatures;
     import org.keycloak.testsuite.util.AdminClientUtil;
    @@ -129,6 +135,61 @@ public void testExternalInternalTokenExchange() throws Exception {
             }
         }
     
    +    @Test
    +    public void testSupportedTokenTypesWhenValidatingSubjectToken() throws Exception {
    +        testingClient.server().run(KcOidcBrokerTokenExchangeTest::setupRealm);
    +        RealmResource providerRealm = realmsResouce().realm(bc.providerRealmName());
    +        ClientsResource clients = providerRealm.clients();
    +        ClientRepresentation brokerApp = clients.findByClientId("brokerapp").get(0);
    +        brokerApp.setDirectAccessGrantsEnabled(true);
    +        ClientResource brokerAppResource = providerRealm.clients().get(brokerApp.getId());
    +        brokerAppResource.update(brokerApp);
    +        RealmResource consumerRealm = realmsResouce().realm(bc.consumerRealmName());
    +        IdentityProviderResource identityProviderResource = consumerRealm.identityProviders().get(bc.getIDPAlias());
    +        IdentityProviderRepresentation idpRep = identityProviderResource.toRepresentation();
    +        idpRep.getConfig().put("disableUserInfo", "true");
    +        identityProviderResource.update(idpRep);
    +        getCleanup().addCleanup(() -> {
    +            idpRep.getConfig().put("disableUserInfo", "false");
    +            identityProviderResource.update(idpRep);
    +        });
    +
    +        OAuthClient.AccessTokenResponse tokenResponse = oauth.doGrantAccessTokenRequest(bc.providerRealmName(), bc.getUserLogin(), bc.getUserPassword(), null, brokerApp.getClientId(), brokerApp.getSecret());
    +        assertThat(tokenResponse.getIdToken(), notNullValue());
    +        String idTokenString = tokenResponse.getIdToken();
    +        oauth.realm(bc.providerRealmName());
    +        String logoutUrl = oauth.getLogoutUrl().idTokenHint(idTokenString)
    +                .postLogoutRedirectUri(oauth.APP_AUTH_ROOT).build();
    +        driver.navigate().to(logoutUrl);
    +        String logoutToken = testingClient.testApp().getBackChannelRawLogoutToken();
    +        Assert.assertNotNull(logoutToken);
    +
    +        Client httpClient = AdminClientUtil.createResteasyClient();
    +        try {
    +            WebTarget exchangeUrl = httpClient.target(OAuthClient.AUTH_SERVER_ROOT)
    +                    .path("/realms")
    +                    .path(bc.consumerRealmName())
    +                    .path("protocol/openid-connect/token");
    +            // test user info validation.
    +            try (Response response = exchangeUrl.request()
    +                    .header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader(
    +                            "test-app", "secret"))
    +                    .post(Entity.form(
    +                            new Form()
    +                                    .param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)
    +                                    .param(OAuth2Constants.SUBJECT_TOKEN, logoutToken)
    +                                    .param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.JWT_TOKEN_TYPE)
    +                                    .param(OAuth2Constants.SUBJECT_ISSUER, bc.getIDPAlias())
    +                                    .param(OAuth2Constants.SCOPE, OAuth2Constants.SCOPE_OPENID)
    +
    +                    ))) {
    +                assertThat(response.getStatus(), equalTo(Status.BAD_REQUEST.getStatusCode()));
    +            }
    +        } finally {
    +            httpClient.close();
    +        }
    +    }
    +
         private static void setupRealm(KeycloakSession session) {
             RealmModel realm = session.realms().getRealmByName(BrokerTestConstants.REALM_CONS_NAME);
             IdentityProviderModel idp = realm.getIdentityProviderByAlias(IDP_OIDC_ALIAS);
    @@ -151,5 +212,10 @@ private static void setupRealm(KeycloakSession session) {
             ResourceServer server = management.realmResourceServer();
             Policy clientPolicy = management.authz().getStoreFactory().getPolicyStore().create(server, clientRep);
             management.idps().exchangeToPermission(idp).addAssociatedPolicy(clientPolicy);
    +
    +        realm = session.realms().getRealmByName(BrokerTestConstants.REALM_PROV_NAME);
    +        client = realm.getClientByClientId("brokerapp");
    +        client.addRedirectUri(OAuthClient.APP_ROOT + "/auth");
    +        client.setAttribute(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL, OAuthClient.APP_ROOT + "/admin/backchannelLogout");
         }
     }
    
  • testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LevelOfAssuranceFlowTest.java+252 6 modified
    @@ -21,46 +21,64 @@
     import java.util.Arrays;
     import java.util.Collections;
     import java.util.HashMap;
    +import java.util.List;
     import java.util.Map;
     
     import jakarta.ws.rs.BadRequestException;
    +import jakarta.ws.rs.core.Response;
     import jakarta.ws.rs.core.UriBuilder;
     import org.jboss.arquillian.graphene.page.Page;
     import org.junit.After;
    -import org.junit.Assert;
     import org.junit.Before;
     import org.junit.Rule;
     import org.junit.Test;
     import org.keycloak.admin.client.resource.ClientResource;
     import org.keycloak.admin.client.resource.RealmResource;
    +import org.keycloak.admin.client.resource.UserResource;
    +import org.keycloak.authentication.AuthenticationFlow;
    +import org.keycloak.authentication.authenticators.browser.OTPFormAuthenticator;
     import org.keycloak.authentication.authenticators.browser.OTPFormAuthenticatorFactory;
    +import org.keycloak.authentication.authenticators.browser.RecoveryAuthnCodesFormAuthenticatorFactory;
     import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory;
     import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticator;
     import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticatorFactory;
    +import org.keycloak.broker.provider.util.SimpleHttp;
     import org.keycloak.common.Profile;
     import org.keycloak.events.Details;
    +import org.keycloak.events.EventType;
     import org.keycloak.models.AuthenticationExecutionModel.Requirement;
     import org.keycloak.models.Constants;
    +import org.keycloak.models.UserModel;
    +import org.keycloak.models.credential.OTPCredentialModel;
    +import org.keycloak.models.credential.RecoveryAuthnCodesCredentialModel;
     import org.keycloak.models.utils.TimeBasedOTP;
     import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
     import org.keycloak.protocol.oidc.OIDCLoginProtocol;
     import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory;
     import org.keycloak.representations.ClaimsRepresentation;
     import org.keycloak.representations.IDToken;
     import org.keycloak.representations.idm.ClientRepresentation;
    +import org.keycloak.representations.idm.CredentialRepresentation;
     import org.keycloak.representations.idm.EventRepresentation;
     import org.keycloak.representations.idm.RealmRepresentation;
     import org.keycloak.representations.idm.UserRepresentation;
     import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
    +import org.keycloak.testsuite.Assert;
     import org.keycloak.testsuite.AssertEvents;
    +import org.keycloak.testsuite.account.AccountRestClient;
     import org.keycloak.testsuite.admin.ApiUtil;
     import org.keycloak.testsuite.arquillian.annotation.DisableFeature;
    +import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
     import org.keycloak.testsuite.authentication.PushButtonAuthenticatorFactory;
     import org.keycloak.testsuite.client.KeycloakTestingClient;
    +import org.keycloak.testsuite.pages.DeleteCredentialPage;
     import org.keycloak.testsuite.pages.ErrorPage;
    +import org.keycloak.testsuite.pages.LoginConfigTotpPage;
     import org.keycloak.testsuite.pages.LoginPage;
     import org.keycloak.testsuite.pages.LoginTotpPage;
     import org.keycloak.testsuite.pages.PushTheButtonPage;
    +import org.keycloak.testsuite.pages.SelectAuthenticatorPage;
    +import org.keycloak.testsuite.pages.SetupRecoveryAuthnCodesPage;
     import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
     import org.keycloak.testsuite.util.FlowUtil;
     import org.keycloak.testsuite.util.OAuthClient;
    @@ -69,14 +87,19 @@
     import org.keycloak.util.JsonSerialization;
     
     import static org.hamcrest.CoreMatchers.is;
    +import static org.keycloak.common.Profile.Feature.RECOVERY_CODES;
    +import static org.keycloak.testsuite.actions.AppInitiatedActionDeleteCredentialTest.getKcActionParamForDeleteCredential;
     
     /**
      * Tests for Level Of Assurance conditions in authentication flow.
      *
      * @author <a href="mailto:sebastian.zoescher@prime-sign.com">Sebastian Zoescher</a>
      */
    +@EnableFeature(value = RECOVERY_CODES, skipRestart = true)
     public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
     
    +    private static final String FLOW_NAME = "";
    +
         @Rule
         public AssertEvents events = new AssertEvents(this);
     
    @@ -86,6 +109,15 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
         @Page
         protected LoginTotpPage loginTotpPage;
     
    +    @Page
    +    protected LoginConfigTotpPage totpSetupPage;
    +
    +    @Page
    +    protected SetupRecoveryAuthnCodesPage setupRecoveryAuthnCodesPage;
    +
    +    @Page
    +    protected SelectAuthenticatorPage selectAuthenticatorPage;
    +
         private TimeBasedOTP totp = new TimeBasedOTP();
     
         @Page
    @@ -94,6 +126,9 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
         @Page
         protected ErrorPage errorPage;
     
    +    @Page
    +    protected DeleteCredentialPage deleteCredentialPage;
    +
         @Override
         public void configureTestRealm(RealmRepresentation testRealm) {
             try {
    @@ -108,6 +143,24 @@ public void configureTestRealm(RealmRepresentation testRealm) {
             }
         }
     
    +    @Before
    +    public void beforeTest() {
    +        UserResource user = ApiUtil.findUserByUsernameId(testRealm(), "test-user@localhost");
    +        UserRepresentation userRep = user.toRepresentation();
    +        user.remove();
    +
    +        userRep.setId(null);
    +        UserBuilder.edit(userRep)
    +                .password("password")
    +                .totpSecret("totpSecret")
    +                .otpEnabled();
    +        Response response = testRealm().users().create(userRep);
    +        Assert.assertEquals(201, response.getStatus());
    +        response.close();
    +
    +        oauth.kcAction(null);
    +    }
    +
         private String getAcrToLoaMappingForClient() throws IOException {
             Map<String, Integer> acrLoaMap = new HashMap<>();
             acrLoaMap.put("copper", 0);
    @@ -144,7 +197,7 @@ private static void configureStepUpFlow(KeycloakTestingClient testingClient, int
             testingClient.server(TEST_REALM_NAME)
                     .run(session -> FlowUtil.inCurrentRealm(session).selectFlow(newFlowAlias).inForms(forms -> forms.clear()
                             // level 1 authentication
    -                        .addSubFlowExecution(Requirement.CONDITIONAL, subFlow -> {
    +                        .addSubFlowExecution("level1-subflow", AuthenticationFlow.BASIC_FLOW, Requirement.CONDITIONAL, subFlow -> {
                                 subFlow.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID,
                                         config -> {
                                             config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "1");
    @@ -156,7 +209,7 @@ private static void configureStepUpFlow(KeycloakTestingClient testingClient, int
                             })
     
                             // level 2 authentication
    -                        .addSubFlowExecution(Requirement.CONDITIONAL, subFlow -> {
    +                        .addSubFlowExecution("level2-subflow", AuthenticationFlow.BASIC_FLOW, Requirement.CONDITIONAL, subFlow -> {
                                 subFlow.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID,
                                         config -> {
                                             config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "2");
    @@ -168,7 +221,7 @@ private static void configureStepUpFlow(KeycloakTestingClient testingClient, int
                             })
     
                             // level 3 authentication
    -                        .addSubFlowExecution(Requirement.CONDITIONAL, subFlow -> {
    +                        .addSubFlowExecution("level3-subflow", AuthenticationFlow.BASIC_FLOW, Requirement.CONDITIONAL, subFlow -> {
                                 subFlow.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID,
                                         config -> {
                                             config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "3");
    @@ -187,6 +240,21 @@ private void reconfigureStepUpFlow(int maxAge1, int maxAge2, int maxAge3) {
             configureStepUpFlow(testingClient, maxAge1, maxAge2, maxAge3);
         }
     
    +    private static void configureFlowsWithRecoveryCodes(KeycloakTestingClient testingClient) {
    +        final String newFlowAlias = "browser -  Level of Authentication FLow";
    +
    +        testingClient.server(TEST_REALM_NAME)
    +                .run(session -> {
    +                    FlowUtil.inCurrentRealm(session).selectFlow(newFlowAlias).inForms(forms ->
    +                                    // Remove "OTP" required execution
    +                                    forms.selectFlow("level2-subflow")
    +                                            .removeExecution(1)
    +                                            .addAuthenticatorExecution(Requirement.ALTERNATIVE, OTPFormAuthenticatorFactory.PROVIDER_ID)
    +                                            .addAuthenticatorExecution(Requirement.ALTERNATIVE, RecoveryAuthnCodesFormAuthenticatorFactory.PROVIDER_ID)
    +                    );
    +                });
    +    }
    +
         @After
         public void after() {
             BrowserFlowTest.revertFlows(testRealm(), "browser -  Level of Authentication FLow");
    @@ -668,6 +736,172 @@ public void testDisableStepupFeatureInNewRealm() {
             }
         }
     
    +    @Test
    +    public void testWithMultipleOTPCodes() throws Exception {
    +        // Get regular authentication. Only level1 required.
    +        oauth.openLoginForm();
    +        // Authentication without specific LOA results in level 1 authentication
    +        authenticateWithUsernamePassword();
    +        TokenCtx token1 = assertLoggedInWithAcr("silver");
    +
    +        // Add "kc_action" for setup another OTP. Existing OTP authentication should be required. No offer for recovery-codes as they are different level
    +        oauth.kcAction(UserModel.RequiredAction.CONFIGURE_TOTP.name());
    +        oauth.openLoginForm();
    +        loginTotpPage.assertCurrent();
    +        loginTotpPage.assertOtpCredentialSelectorAvailability(false);
    +
    +        authenticateWithTotp();
    +        totpSetupPage.assertCurrent();
    +        totpSetupPage.configure(totp.generateTOTP(totpSetupPage.getTotpSecret()), "totp2-label");
    +        events.expectRequiredAction(EventType.UPDATE_TOTP).assertEvent();
    +        TokenCtx token2 = assertLoggedInWithAcr("gold");
    +
    +        // Trying to add another OTP by "kc_action". Level 2 should be required and user can choose between 2 OTP codes
    +        oauth.kcAction(UserModel.RequiredAction.CONFIGURE_TOTP.name());
    +        oauth.openLoginForm();
    +        loginTotpPage.assertCurrent();
    +        loginTotpPage.assertOtpCredentialSelectorAvailability(true);
    +        List<String> availableOtps = loginTotpPage.getAvailableOtpCredentials();
    +        Assert.assertNames(availableOtps, OTPFormAuthenticator.UNNAMED, "totp2-label");
    +
    +        // Removing 2nd OTP by account REST API with regular token. Should fail as acr=2 is required
    +        String otpCredentialId;
    +        try (AccountRestClient accountRestClient = AccountRestClient
    +                .builder(suiteContext)
    +                .accessToken(token1.accessToken)
    +                .build()) {
    +            otpCredentialId = accountRestClient.getCredentialByUserLabel("totp2-label").getId();
    +            try (SimpleHttp.Response response = accountRestClient.removeCredential(otpCredentialId)) {
    +                Assert.assertEquals(403, response.getStatus());
    +            }
    +        }
    +
    +        // Removing 2nd OTP by account REST API with level2 token. Should work as acr=2 is required
    +        try (AccountRestClient accountRestClient = AccountRestClient
    +                .builder(suiteContext)
    +                .accessToken(token2.accessToken)
    +                .build()) {
    +            otpCredentialId = accountRestClient.getCredentialByUserLabel("totp2-label").getId();
    +            try (SimpleHttp.Response response = accountRestClient.removeCredential(otpCredentialId)) {
    +                Assert.assertEquals(204, response.getStatus());
    +            }
    +            Assert.assertNull(accountRestClient.getCredentialByUserLabel("totp2-label"));
    +        }
    +    }
    +
    +    @Test
    +    public void testDeleteCredentialAction() throws Exception {
    +        // Login level1
    +        oauth.openLoginForm();
    +        authenticateWithUsernamePassword();
    +        TokenCtx token1 = assertLoggedInWithAcr("silver");
    +
    +        // Setup another OTP (requires login with existing OTP)
    +        oauth.kcAction(UserModel.RequiredAction.CONFIGURE_TOTP.name());
    +        oauth.openLoginForm();
    +        authenticateWithTotp();
    +        totpSetupPage.assertCurrent();
    +        totpSetupPage.configure(totp.generateTOTP(totpSetupPage.getTotpSecret()), "totp2-label");
    +        events.expectRequiredAction(EventType.UPDATE_TOTP).assertEvent();
    +        TokenCtx token2 = assertLoggedInWithAcr("gold");
    +
    +        String otp2CredentialId = getCredentialIdByLabel("totp2-label");
    +
    +        // Delete OTP credential requires level2. Re-authentication is required (because of max_age=0 for level2 evaluated during re-authentication)
    +        oauth.kcAction(getKcActionParamForDeleteCredential(otp2CredentialId));
    +        oauth.openLoginForm();
    +        loginTotpPage.assertCurrent();
    +        authenticateWithTotp();
    +
    +        deleteCredentialPage.assertCurrent();
    +        deleteCredentialPage.assertCredentialInMessage("totp2-label");
    +        deleteCredentialPage.confirm();
    +
    +        events.expectRequiredAction(EventType.REMOVE_TOTP).assertEvent();
    +        assertLoggedInWithAcr("gold");
    +    }
    +
    +    @Test
    +    public void testWithOTPAndRecoveryCodesAtLevel2() {
    +        configureFlowsWithRecoveryCodes(testingClient);
    +        try {
    +            // Get regular authentication. Only level1 required.
    +            oauth.openLoginForm();
    +            authenticateWithUsernamePassword();
    +            TokenCtx token1 = assertLoggedInWithAcr("silver");
    +
    +            // Trying to delete existing OTP. Should require authentication with this OTP
    +            String otpCredentialId = getCredentialIdByType(OTPCredentialModel.TYPE);
    +            oauth.kcAction(getKcActionParamForDeleteCredential(otpCredentialId));
    +            oauth.openLoginForm();
    +            Assert.assertEquals("Strong authentication required to continue", loginPage.getInfoMessage());
    +            authenticateWithTotp();
    +
    +            deleteCredentialPage.assertCurrent();
    +            deleteCredentialPage.assertCredentialInMessage("otp");
    +            deleteCredentialPage.confirm();
    +            events.expectRequiredAction(EventType.REMOVE_TOTP).assertEvent();
    +            assertLoggedInWithAcr("gold");
    +
    +            // Trying to add OTP. No 2nd factor should be required as user doesn't have any
    +            oauth.kcAction(UserModel.RequiredAction.CONFIGURE_TOTP.name());
    +            oauth.openLoginForm();
    +            totpSetupPage.assertCurrent();
    +            String totp2Secret = totpSetupPage.getTotpSecret();
    +            totpSetupPage.configure(totp.generateTOTP(totp2Secret), "totp2-label");
    +            events.expectRequiredAction(EventType.UPDATE_TOTP).assertEvent();
    +            assertLoggedInWithAcr("silver");
    +
    +            // set time offset for OTP as it is not permitted to authenticate with same OTP code multiple times
    +            setOtpTimeOffset(TimeBasedOTP.DEFAULT_INTERVAL_SECONDS, totp);
    +
    +            // Add "kc_action" for setup recovery codes. OTP should be required
    +            oauth.kcAction(UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name());
    +            oauth.openLoginForm();
    +            loginTotpPage.assertCurrent();
    +            loginTotpPage.login(totp.generateTOTP(totp2Secret));
    +            setupRecoveryAuthnCodesPage.assertCurrent();
    +            setupRecoveryAuthnCodesPage.clickSaveRecoveryAuthnCodesButton();
    +            events.expectRequiredAction(EventType.CUSTOM_REQUIRED_ACTION).assertEvent();
    +            assertLoggedInWithAcr("gold");
    +
    +            // Removing recovery-code credential. User required to authenticate with 2nd-factor. He can choose between OTP or recovery-codes
    +            String recoveryCodesId = getCredentialIdByType(RecoveryAuthnCodesCredentialModel.TYPE);
    +            oauth.kcAction(getKcActionParamForDeleteCredential(recoveryCodesId));
    +            oauth.openLoginForm();
    +            loginTotpPage.assertCurrent();
    +            loginTotpPage.clickTryAnotherWayLink();
    +            selectAuthenticatorPage.assertCurrent();
    +            Assert.assertEquals(Arrays.asList(SelectAuthenticatorPage.AUTHENTICATOR_APPLICATION, SelectAuthenticatorPage.RECOVERY_AUTHN_CODES), selectAuthenticatorPage.getAvailableLoginMethods());
    +            selectAuthenticatorPage.selectLoginMethod(SelectAuthenticatorPage.AUTHENTICATOR_APPLICATION);
    +            loginTotpPage.assertCurrent();
    +            loginTotpPage.login(totp.generateTOTP(totp2Secret));
    +
    +            deleteCredentialPage.assertCurrent();
    +            deleteCredentialPage.assertCredentialInMessage("Recovery codes");
    +            deleteCredentialPage.confirm();
    +            events.expectRequiredAction(EventType.CUSTOM_REQUIRED_ACTION).assertEvent();
    +            assertLoggedInWithAcr("gold");
    +        } finally {
    +            setOtpTimeOffset(0, totp);
    +        }
    +    }
    +
    +    private String getCredentialIdByLabel(String credentialLabel) {
    +        return ApiUtil.findUserByUsernameId(testRealm(), "test-user@localhost").credentials()
    +                .stream()
    +                .filter(credential -> "totp2-label".equals(credential.getUserLabel()))
    +                .map(CredentialRepresentation::getId)
    +                .findFirst().orElseThrow(() -> new IllegalStateException("Did not found credential with label " + credentialLabel));
    +    }
    +
    +    private String getCredentialIdByType(String credentialType) {
    +        return ApiUtil.findUserByUsernameId(testRealm(), "test-user@localhost").credentials()
    +                .stream()
    +                .filter(credential -> credentialType.equals(credential.getType()))
    +                .map(CredentialRepresentation::getId)
    +                .findFirst().orElseThrow(() -> new IllegalStateException("Did not found credential with OTP type on the user"));
    +    }
     
         public void openLoginFormWithAcrClaim(boolean essential, String... acrValues) {
             openLoginFormWithAcrClaim(oauth, essential, acrValues);
    @@ -706,15 +940,27 @@ private void authenticateWithButton() {
             pushTheButtonPage.submit();
         }
     
    -    private void assertLoggedInWithAcr(String acr) {
    +    private TokenCtx assertLoggedInWithAcr(String acr) {
             EventRepresentation loginEvent = events.expectLogin().detail(Details.USERNAME, "test-user@localhost").assertEvent();
    -        IDToken idToken = sendTokenRequestAndGetIDToken(loginEvent);
    +        OAuthClient.AccessTokenResponse tokenResponse = sendTokenRequestAndGetResponse(loginEvent);
    +        IDToken idToken = oauth.verifyIDToken(tokenResponse.getIdToken());
             Assert.assertEquals(acr, idToken.getAcr());
    +        return new TokenCtx(tokenResponse.getAccessToken(), idToken);
         }
     
         private void assertErrorPage(String expectedError) {
             Assert.assertThat(true, is(errorPage.isCurrent()));
             Assert.assertEquals(expectedError, errorPage.getError());
             events.clear();
         }
    +
    +    private class TokenCtx {
    +        private String accessToken;
    +        private IDToken idToken;
    +
    +        private TokenCtx(String accessToken, IDToken idToken) {
    +            this.accessToken = accessToken;
    +            this.idToken = idToken;
    +        }
    +    }
     }
    
  • testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/AbstractMigrationTest.java+18 1 modified
    @@ -86,7 +86,6 @@
     import java.util.regex.Pattern;
     import java.util.stream.Collectors;
     
    -import static net.bytebuddy.matcher.ElementMatchers.is;
     import static org.hamcrest.CoreMatchers.not;
     import static org.hamcrest.MatcherAssert.assertThat;
     import static org.hamcrest.Matchers.allOf;
    @@ -363,6 +362,10 @@ protected void testMigrationTo22_0_0() {
             testHttpChallengeFlow(migrationRealm);
         }
     
    +    protected void testMigrationTo22_0_10() {
    +        testDeleteCredentialActionAvailable(migrationRealm);
    +    }
    +
         protected void testDeleteAccount(RealmResource realm) {
             ClientRepresentation accountClient = realm.clients().findByClientId(ACCOUNT_MANAGEMENT_CLIENT_ID).get(0);
             ClientResource accountResource = realm.clients().get(accountClient.getId());
    @@ -877,6 +880,8 @@ private void testRequiredActionsPriority(RealmResource... realms) {
                 for (RequiredActionProviderRepresentation action : actions) {
                     if (action.getAlias().equals("update_user_locale")) {
                         assertEquals(1000, action.getPriority());
    +                } else if (action.getAlias().equals("delete_credential")) {
    +                    assertEquals(100, action.getPriority());
                     } else {
                         assertEquals(priority, action.getPriority());
                     }
    @@ -1041,6 +1046,7 @@ protected void testMigrationTo21_x() {
     
         protected void testMigrationTo22_x() {
             testMigrationTo22_0_0();
    +        testMigrationTo22_0_10();
         }
     
         protected void testMigrationTo7_x(boolean supportedAuthzServices) {
    @@ -1147,4 +1153,15 @@ protected void testRealmAttributesMigration() {
             Map<String, String> realmAttributes = migrationRealm.toRepresentation().getAttributes();
             assertEquals("custom_value", realmAttributes.get("custom_attribute"));
         }
    +
    +    private void testDeleteCredentialActionAvailable(RealmResource realm) {
    +        RequiredActionProviderRepresentation rep = realm.flows().getRequiredAction("delete_credential");
    +        assertNotNull(rep);
    +        assertEquals("delete_credential", rep.getAlias());
    +        assertEquals("delete_credential", rep.getProviderId());
    +        assertEquals("Delete Credential", rep.getName());
    +        assertEquals(100, rep.getPriority());
    +        assertTrue(rep.isEnabled());
    +        assertFalse(rep.isDefaultAction());
    +    }
     }
    
  • testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientTokenExchangeTest.java+34 0 modified
    @@ -17,11 +17,13 @@
     
     package org.keycloak.testsuite.oauth;
     
    +import jakarta.ws.rs.core.Response.Status;
     import org.junit.Rule;
     import org.junit.Test;
     import org.keycloak.OAuth2Constants;
     import org.keycloak.TokenVerifier;
     import org.keycloak.admin.client.resource.ClientResource;
    +import org.keycloak.admin.client.resource.ClientsResource;
     import org.keycloak.authorization.model.Policy;
     import org.keycloak.authorization.model.ResourceServer;
     import org.keycloak.common.Profile;
    @@ -63,6 +65,7 @@
     import jakarta.ws.rs.core.Response;
     import java.util.List;
     import java.util.Map;
    +import java.util.concurrent.TimeUnit;
     
     import static org.hamcrest.Matchers.instanceOf;
     import static org.junit.Assert.assertEquals;
    @@ -165,6 +168,7 @@ public static void setupRealm(KeycloakSession session) {
             directLegal.setSecret("secret");
             directLegal.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
             directLegal.setFullScopeAllowed(false);
    +        directLegal.addRedirectUri(OAuthClient.APP_ROOT + "/auth");
     
             ClientModel directPublic = realm.addClient("direct-public");
             directPublic.setClientId("direct-public");
    @@ -909,6 +913,36 @@ public void testPublicClientNotAllowed() throws Exception {
             assertEquals("Client is not within the token audience", response.getErrorDescription());
         }
     
    +    @Test
    +    public void testSupportedTokenTypesWhenValidatingSubjectToken() throws Exception {
    +        testingClient.server().run(ClientTokenExchangeTest::setupRealm);
    +        oauth.realm(TEST);
    +        oauth.clientId("direct-legal");
    +        oauth.scope(OAuth2Constants.SCOPE_OPENID);
    +        ClientsResource clients = adminClient.realm(oauth.getRealm()).clients();
    +        ClientRepresentation rep = clients.findByClientId(oauth.getClientId()).get(0);
    +        rep.getAttributes().put(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL, oauth.APP_ROOT + "/admin/backchannelLogout");
    +        getCleanup().addCleanup(() -> {
    +            rep.getAttributes().put(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL, "");
    +            clients.get(rep.getId()).update(rep);
    +        });
    +        clients.get(rep.getId()).update(rep);
    +        String logoutToken;
    +        oauth.clientSessionState("client-session");
    +        oauth.doLogin("user", "password");
    +        String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
    +        OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "secret");
    +        String idTokenString = tokenResponse.getIdToken();
    +        String logoutUrl = oauth.getLogoutUrl().idTokenHint(idTokenString)
    +                .postLogoutRedirectUri(oauth.APP_AUTH_ROOT).build();
    +        driver.navigate().to(logoutUrl);
    +        logoutToken = testingClient.testApp().getBackChannelRawLogoutToken();
    +        Assert.assertNotNull(logoutToken);
    +        OAuthClient.AccessTokenResponse response = oauth.doTokenExchange(TEST, logoutToken, "target", "direct-legal", "secret");
    +        assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
    +
    +    }
    +
         private static void addDirectExchanger(KeycloakSession session) {
             RealmModel realm = session.realms().getRealmByName(TEST);
             RoleModel exampleRole = realm.addRole("example");
    
  • testsuite/integration-arquillian/tests/other/base-ui/src/main/java/org/keycloak/testsuite/ui/account2/page/utils/SigningInPageUtils.java+11 6 modified
    @@ -21,6 +21,7 @@
     import org.keycloak.admin.client.resource.UserResource;
     import org.keycloak.representations.idm.CredentialRepresentation;
     import org.keycloak.representations.idm.RequiredActionProviderRepresentation;
    +import org.keycloak.testsuite.pages.DeleteCredentialPage;
     import org.keycloak.testsuite.ui.account2.page.AbstractLoggedInPage;
     import org.keycloak.testsuite.ui.account2.page.SigningInPage;
     
    @@ -78,14 +79,18 @@ public static void testSetUpLink(RealmResource realmResource, SigningInPage.Cred
             assertThat("Set up link for \"" + credentialType.getType() + "\" is visible", credentialType.isNotSetUpLabelVisible(), is(false));
         }
     
    -    public static void testRemoveCredential(AbstractLoggedInPage accountPage, SigningInPage.UserCredential userCredential) {
    +    public static void testRemoveCredential(AbstractLoggedInPage accountPage, DeleteCredentialPage deleteCredentialPage, SigningInPage.UserCredential userCredential) {
             int countBeforeRemove = userCredential.getCredentialType().getUserCredentialsCount();
    +        userCredential.clickRemoveBtn();
     
    -        testModalDialog(accountPage, userCredential::clickRemoveBtn, () -> {
    -            assertThat(userCredential.isPresent(), is(true));
    -            assertThat(userCredential.getCredentialType().getUserCredentialsCount(), is(countBeforeRemove));
    -        });
    -        accountPage.alert().assertSuccess();
    +        deleteCredentialPage.assertCurrent();
    +        deleteCredentialPage.cancel();
    +        accountPage.assertCurrent();
    +        assertThat(userCredential.isPresent(), is(true));
    +        assertThat(userCredential.getCredentialType().getUserCredentialsCount(), is(countBeforeRemove));
    +        userCredential.clickRemoveBtn();
    +        deleteCredentialPage.assertCurrent();
    +        deleteCredentialPage.confirm();
     
             assertThat(userCredential.isPresent(), is(false));
             assertThat(userCredential.getCredentialType().getUserCredentialsCount(), is(countBeforeRemove - 1));
    
  • testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/SigningInTest.java+5 1 modified
    @@ -29,6 +29,7 @@
     import org.keycloak.testsuite.admin.Users;
     import org.keycloak.testsuite.auth.page.login.OTPSetup;
     import org.keycloak.testsuite.auth.page.login.UpdatePassword;
    +import org.keycloak.testsuite.pages.DeleteCredentialPage;
     import org.keycloak.testsuite.ui.account2.page.AbstractLoggedInPage;
     import org.keycloak.testsuite.ui.account2.page.SigningInPage;
     import org.keycloak.testsuite.ui.account2.page.utils.SigningInPageUtils;
    @@ -58,6 +59,9 @@ public class SigningInTest extends BaseAccountPageTest {
         @Page
         private UpdatePassword updatePasswordPage;
     
    +    @Page
    +    private DeleteCredentialPage deleteCredentialPage;
    +
         @Page
         private OTPSetup otpSetupPage;
     
    @@ -214,6 +218,6 @@ private SigningInPage.UserCredential getNewestUserCredential(SigningInPage.Crede
         }
     
         private void testRemoveCredential(SigningInPage.UserCredential userCredential) {
    -        SigningInPageUtils.testRemoveCredential(getAccountPage(), userCredential);
    +        SigningInPageUtils.testRemoveCredential(getAccountPage(), deleteCredentialPage, userCredential);
         }
     }
    
  • testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/account/AbstractWebAuthnAccountTest.java+5 1 modified
    @@ -35,6 +35,7 @@
     import org.keycloak.testsuite.AbstractAuthTest;
     import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
     import org.keycloak.testsuite.page.AbstractPatternFlyAlert;
    +import org.keycloak.testsuite.pages.DeleteCredentialPage;
     import org.keycloak.testsuite.ui.account2.page.SigningInPage;
     import org.keycloak.testsuite.ui.account2.page.utils.SigningInPageUtils;
     import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
    @@ -67,6 +68,9 @@ public abstract class AbstractWebAuthnAccountTest extends AbstractAuthTest imple
         @Page
         protected WebAuthnLoginPage webAuthnLoginPage;
     
    +    @Page
    +    private DeleteCredentialPage deleteCredentialPage;
    +
         private VirtualAuthenticatorManager webAuthnManager;
         protected SigningInPage.CredentialType webAuthnCredentialType;
         protected SigningInPage.CredentialType webAuthnPwdlessCredentialType;
    @@ -188,7 +192,7 @@ protected SigningInPage.UserCredential addWebAuthnCredential(String label, boole
     
         protected void testRemoveCredential(SigningInPage.UserCredential userCredential) {
             AbstractPatternFlyAlert.waitUntilHidden();
    -        SigningInPageUtils.testRemoveCredential(signingInPage, userCredential);
    +        SigningInPageUtils.testRemoveCredential(signingInPage, deleteCredentialPage, userCredential);
         }
     
         protected SigningInPage.UserCredential getNewestUserCredential(SigningInPage.CredentialType credentialType) {
    
  • themes/src/main/resources/theme/base/login/delete-credential.ftl+15 0 added
    @@ -0,0 +1,15 @@
    +<#import "template.ftl" as layout>
    +<@layout.registrationLayout displayMessage=false; section>
    +    <#if section = "header">
    +        ${msg("deleteCredentialTitle", credentialLabel)}
    +    <#elseif section = "form">
    +    <div id="kc-delete-text">
    +        ${msg("deleteCredentialMessage", credentialLabel)}
    +    </div>
    +    <form class="form-actions" action="${url.loginAction}" method="POST">
    +        <input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}" name="accept" id="kc-accept" type="submit" value="${msg("doConfirmDelete")}"/>
    +        <input class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonLargeClass!}" name="cancel-aia" value="${msg("doCancel")}" id="kc-decline" type="submit" />
    +    </form>
    +    <div class="clearfix"></div>
    +    </#if>
    +</@layout.registrationLayout>
    
  • themes/src/main/resources/theme/base/login/messages/messages_en.properties+4 0 modified
    @@ -34,6 +34,7 @@ loginProfileTitle=Update Account Information
     loginIdpReviewProfileTitle=Update Account Information
     loginTimeout=Your login attempt timed out.  Login will start from the beginning.
     reauthenticate=Please re-authenticate to continue
    +authenticateStrong=Strong authentication required to continue
     oauthGrantTitle=Grant Access to {0}
     oauthGrantTitleHtml={0}
     oauthGrantInformation=Make sure you trust {0} by learning how {0} will handle your data.
    @@ -71,6 +72,9 @@ termsPlainText=Terms and conditions to be defined.
     termsAcceptanceRequired=You must agree to our terms and conditions.
     acceptTerms=I agree to the terms and conditions
     
    +deleteCredentialTitle=Delete {0}
    +deleteCredentialMessage=Do you want to delete {0}?
    +
     recaptchaFailed=Invalid Recaptcha
     recaptchaNotConfigured=Recaptcha is required, but not configured
     consentDenied=Consent denied.
    
  • themes/src/main/resources/theme/keycloak.v2/account/src/app/content/signingin-page/SigningInPage.tsx+14 8 modified
    @@ -305,6 +305,7 @@ class SigningInPage extends React.Component<
                                     removeable={removeable}
                                     updateAction={updateAIA}
                                     credRemover={this.handleRemove}
    +                                keycloak={keycloak}
                                   />
                               </DataListItemRow>
                           </DataListItem>
    @@ -471,6 +472,7 @@ interface CredentialActionProps {
         removeable: boolean;
         updateAction: AIACommand;
         credRemover: CredRemover;
    +    keycloak: KeycloakService;
     };
     
     class CredentialAction extends React.Component<CredentialActionProps> {
    @@ -499,20 +501,24 @@ class CredentialAction extends React.Component<CredentialActionProps> {
     
             if (this.props.removeable) {
                 const userLabel: string = this.props.credential.userLabel;
    +            const removeAction: AIACommand = new AIACommand(this.props.keycloak, 'delete_credential:' + this.props.credential.id);
                 return (
                     <DataListAction
                       aria-label={Msg.localize('removeCredAriaLabel')}
                       aria-labelledby={Msg.localize('removeCredAriaLabel')}
                       id={'removeAction-' + this.props.credential.id }
                     >
    -                    <ContinueCancelModal 
    -                        buttonTitle='remove'
    -                        buttonVariant='danger'
    -                        buttonId={`${SigningInPage.credElementId(this.props.credential.type, this.props.credential.id, 'remove')}`}
    -                        modalTitle={Msg.localize('removeCred', [userLabel])}
    -                        modalMessage={Msg.localize('stopUsingCred', [userLabel])}
    -                        onContinue={() => this.props.credRemover(this.props.credential.id, userLabel)}
    -                    />
    +                    <Button
    +                        variant="danger"
    +                        id={`${SigningInPage.credElementId(
    +                            this.props.credential.type,
    +                            this.props.credential.id,
    +                            "remove"
    +                        )}`}
    +                        onClick={() => removeAction.execute()}
    +                    >
    +                        <Msg msgKey="remove" />
    +                    </Button>
                     </DataListAction>
                 );
             }
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

9

News mentions

0

No linked articles in our index yet.