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.
| Package | Affected versions | Patched versions |
|---|---|---|
org.keycloak:keycloak-servicesMaven | < 22.0.10 | 22.0.10 |
org.keycloak:keycloak-servicesMaven | >= 23.0.0, < 24.0.3 | 24.0.3 |
Patches
1aa634aee8828CVE-2023-3597 - Secondary factor bypass in step-up authentication (#144)
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- github.com/advisories/GHSA-4f53-xh3v-g8x4ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2023-3597ghsaADVISORY
- access.redhat.com/errata/RHSA-2024:1866nvdWEB
- access.redhat.com/errata/RHSA-2024:1867nvdWEB
- access.redhat.com/errata/RHSA-2024:1868nvdWEB
- access.redhat.com/security/cve/CVE-2023-3597nvdWEB
- bugzilla.redhat.com/show_bug.cginvdWEB
- github.com/keycloak/keycloak/commit/aa634aee882892960a526e49982806e103c8a432ghsaWEB
- github.com/keycloak/keycloak/security/advisories/GHSA-4f53-xh3v-g8x4ghsaWEB
News mentions
0No linked articles in our index yet.