Show error in case of an unkown essential acr claim. Make sure correc… (#10088)

* Show error in case of an unkown essential acr claim. Make sure correct acr is set after authentication flow during step-up authentication
Closes #8724

Co-authored-by: Cornelia Lahnsteiner <cornelia.lahnsteiner@prime-sign.com>
Co-authored-by: Martin Bartoš <mabartos@redhat.com>
This commit is contained in:
Marek Posolda 2022-02-15 09:02:05 +01:00 committed by GitHub
parent 589606b1c1
commit 90d4e586b6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 622 additions and 68 deletions

View file

@ -36,4 +36,11 @@ public interface AuthenticationFlowCallback extends Authenticator {
*/
void onParentFlowSuccess(AuthenticationFlowContext context);
/**
* Triggered after the top authentication flow is successfully finished.
* It is really suitable for last verification of successful authentication
*/
default void onTopFlowSuccess() {
}
}

View file

@ -136,6 +136,5 @@ public final class Constants {
public static final String FORCE_LEVEL_OF_AUTHENTICATION = "force-level-of-authentication";
public static final String ACR_LOA_MAP = "acr.loa.map";
public static final int MINIMUM_LOA = 0;
public static final int MAXIMUM_LOA = Integer.MAX_VALUE;
public static final int NO_LOA = -1;
}

View file

@ -710,18 +710,6 @@ public class AuthenticationProcessor {
return status == AuthenticationSessionModel.ExecutionStatus.SUCCESS;
}
public boolean isEvaluatedTrue(AuthenticationExecutionModel model) {
AuthenticationSessionModel.ExecutionStatus status = authenticationSession.getExecutionStatus().get(model.getId());
if (status == null) return false;
return status == AuthenticationSessionModel.ExecutionStatus.EVALUATED_TRUE;
}
public boolean isEvaluatedFalse(AuthenticationExecutionModel model) {
AuthenticationSessionModel.ExecutionStatus status = authenticationSession.getExecutionStatus().get(model.getId());
if (status == null) return false;
return status == AuthenticationSessionModel.ExecutionStatus.EVALUATED_FALSE;
}
public Response handleBrowserExceptionList(AuthenticationFlowException e) {
LoginFormsProvider forms = session.getProvider(LoginFormsProvider.class).setAuthenticationSession(authenticationSession);
ServicesLogger.LOGGER.failedAuthentication(e);

View file

@ -200,7 +200,7 @@ class AuthenticationSelectionResolver {
// For conditional execution, we must check if condition is true. Otherwise return false, which means trying next
// requiredExecution in the list
return !flow.isConditionalSubflowDisabled(ex, false);
return !flow.isConditionalSubflowDisabled(ex);
}).findFirst().orElse(null);

View file

@ -17,13 +17,27 @@
package org.keycloak.authentication;
import com.google.common.collect.Sets;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.Constants;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.sessions.CommonClientSessionModel;
import org.keycloak.utils.StringUtil;
import java.util.Collections;
import java.util.Set;
import static org.keycloak.services.managers.AuthenticationManager.SSO_AUTH;
public class AuthenticatorUtil {
// It is used for identification of note included in authentication session for storing callback provider factories
public static String CALLBACKS_FACTORY_IDS_NOTE = "callbacksFactoryProviderIds";
public static boolean isSSOAuthentication(AuthenticationSessionModel authSession) {
return "true".equals(authSession.getAuthNote(SSO_AUTH));
}
public static boolean isLevelOfAuthenticationForced(AuthenticationSessionModel authSession) {
return Boolean.parseBoolean(authSession.getClientNote(Constants.FORCE_LEVEL_OF_AUTHENTICATION));
}
@ -47,4 +61,46 @@ public class AuthenticatorUtil {
String clientSessionLoaNote = clientSession.getNote(Constants.LEVEL_OF_AUTHENTICATION);
return clientSessionLoaNote == null ? Constants.NO_LOA : Integer.parseInt(clientSessionLoaNote);
}
/**
* Set authentication session note for callbacks defined for {@link AuthenticationFlowCallbackFactory) factories
*
* @param authSession authentication session
* @param authFactoryId authentication factory ID which should be added to the authentication session note
*/
public static void setAuthCallbacksFactoryIds(AuthenticationSessionModel authSession, String authFactoryId) {
if (authSession == null || StringUtil.isBlank(authFactoryId)) return;
final String callbacksFactories = authSession.getAuthNote(CALLBACKS_FACTORY_IDS_NOTE);
if (StringUtil.isNotBlank(callbacksFactories)) {
boolean containsProviderId = callbacksFactories.equals(authFactoryId) ||
callbacksFactories.contains(Constants.CFG_DELIMITER + authFactoryId) ||
callbacksFactories.contains(authFactoryId + Constants.CFG_DELIMITER);
if (!containsProviderId) {
authSession.setAuthNote(CALLBACKS_FACTORY_IDS_NOTE, callbacksFactories + Constants.CFG_DELIMITER + authFactoryId);
}
} else {
authSession.setAuthNote(CALLBACKS_FACTORY_IDS_NOTE, authFactoryId);
}
}
/**
* Get set of Authentication factories IDs defined in authentication session as CALLBACKS_FACTORY_IDS_NOTE
*
* @param authSession authentication session
* @return set of factories IDs
*/
public static Set<String> getAuthCallbacksFactoryIds(AuthenticationSessionModel authSession) {
if (authSession == null) return Collections.emptySet();
final String callbacksFactories = authSession.getAuthNote(CALLBACKS_FACTORY_IDS_NOTE);
if (StringUtil.isNotBlank(callbacksFactories)) {
return Sets.newHashSet(callbacksFactories.split(Constants.CFG_DELIMITER));
} else {
return Collections.emptySet();
}
}
}

View file

@ -27,6 +27,7 @@ import org.keycloak.models.UserModel;
import org.keycloak.services.ServicesLogger;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.sessions.CommonClientSessionModel;
import org.keycloak.utils.StringUtil;
import javax.ws.rs.HttpMethod;
import javax.ws.rs.core.MultivaluedMap;
@ -35,6 +36,8 @@ import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@ -48,7 +51,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
private final List<AuthenticationExecutionModel> executions;
private final AuthenticationProcessor processor;
private final AuthenticationFlowModel flow;
private boolean successful;
private boolean successful = false;
private List<AuthenticationFlowException> afeList = new ArrayList<>();
public DefaultAuthenticationFlow(AuthenticationProcessor processor, AuthenticationFlowModel flow) {
@ -158,6 +161,10 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
AuthenticationProcessor.Result result = processor.createAuthenticatorContext(model, authenticator, executions);
result.setAuthenticationSelections(createAuthenticationSelectionList(model));
if (factory instanceof AuthenticationFlowCallbackFactory) {
AuthenticatorUtil.setAuthCallbacksFactoryIds(processor.getAuthenticationSession(), factory.getId());
}
logger.debugv("action: {0}", model.getAuthenticator());
authenticator.action(result);
Response response = processResult(result, true);
@ -250,7 +257,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
AuthenticationExecutionModel required = requiredIListIterator.next();
//Conditional flows must be considered disabled (non-existent) if their condition evaluates to false.
//If the flow has been processed before it will not be removed to consider its execution status.
if (required.isConditional() && !isProcessed(required) && isConditionalSubflowDisabled(required, true)) {
if (required.isConditional() && !isProcessed(required) && isConditionalSubflowDisabled(required)) {
requiredIListIterator.remove();
continue;
}
@ -270,8 +277,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
if (requiredList.isEmpty()) {
//check if an alternative is already successful, in case we are returning in the flow after an action
if (alternativeList.stream().anyMatch(alternative -> processor.isSuccessful(alternative) || isSetupRequired(alternative))) {
successful = true;
return null;
return onFlowExecutionsSuccessful();
}
//handle alternative elements: the first alternative element to be satisfied is enough
@ -282,8 +288,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
return response;
}
if (processor.isSuccessful(alternative) || isSetupRequired(alternative)) {
successful = true;
return null;
return onFlowExecutionsSuccessful();
}
} catch (AuthenticationFlowException afe) {
//consuming the error is not good here from an administrative point of view, but the user, since he has alternatives, should be able to go to another alternative and continue
@ -292,7 +297,9 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
}
}
} else {
successful = requiredElementsSuccessful;
if (requiredElementsSuccessful) {
return onFlowExecutionsSuccessful();
}
}
return null;
}
@ -326,10 +333,9 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
/**
* Checks if the conditional subflow passed in parameter is disabled.
* @param model
* @param storeResult whether to store the result of the conditional evaluations
* @return
*/
boolean isConditionalSubflowDisabled(AuthenticationExecutionModel model, boolean storeResult) {
boolean isConditionalSubflowDisabled(AuthenticationExecutionModel model) {
if (model == null || !model.isAuthenticatorFlow() || !model.isConditional()) {
return false;
};
@ -339,8 +345,10 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
.filter(this::isConditionalAuthenticator)
.filter(s -> s.isEnabled())
.collect(Collectors.toList());
return conditionalAuthenticatorList.isEmpty() || conditionalAuthenticatorList.stream()
.anyMatch(m -> conditionalNotMatched(m, modelList, storeResult));
boolean conditionalSubflowDisabled = conditionalAuthenticatorList.isEmpty() || conditionalAuthenticatorList.stream()
.anyMatch(m -> conditionalNotMatched(m, modelList));
logger.tracef("Conditional subflow '%s' is %s", logExecutionAlias(model), conditionalSubflowDisabled ? "disabled" : "enabled");
return conditionalSubflowDisabled;
}
private boolean isConditionalAuthenticator(AuthenticationExecutionModel model) {
@ -355,25 +363,16 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
return factory;
}
private boolean conditionalNotMatched(AuthenticationExecutionModel model, List<AuthenticationExecutionModel> executionList, boolean storeResult) {
private boolean conditionalNotMatched(AuthenticationExecutionModel model, List<AuthenticationExecutionModel> executionList) {
AuthenticatorFactory factory = getAuthenticatorFactory(model);
ConditionalAuthenticator authenticator = (ConditionalAuthenticator) createAuthenticator(factory);
AuthenticationProcessor.Result context = processor.createAuthenticatorContext(model, authenticator, executionList);
boolean matchCondition;
// Retrieve previous evaluation result if any, else evaluate and store result for future re-evaluation
if (processor.isEvaluatedTrue(model)) {
matchCondition = true;
} else if (processor.isEvaluatedFalse(model)) {
matchCondition = false;
} else {
matchCondition = authenticator.matchCondition(context);
if (storeResult) {
setExecutionStatus(model,
matchCondition ? AuthenticationSessionModel.ExecutionStatus.EVALUATED_TRUE : AuthenticationSessionModel.ExecutionStatus.EVALUATED_FALSE);
}
}
// Always store result for future re-evaluation. It is a chance that some condition is evaluated multiple times during the flow,
// but this is expected as "conditions of condition" can be changed during the flow (EG. when acr level is reached or when user is added to the context)
boolean matchCondition = authenticator.matchCondition(context);
setExecutionStatus(model,
matchCondition ? AuthenticationSessionModel.ExecutionStatus.EVALUATED_TRUE : AuthenticationSessionModel.ExecutionStatus.EVALUATED_FALSE);
return !matchCondition;
}
@ -547,6 +546,8 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
private void setExecutionStatus(AuthenticationExecutionModel authExecutionModel, CommonClientSessionModel.ExecutionStatus status) {
this.processor.getAuthenticationSession().setExecutionStatus(authExecutionModel.getId(), status);
logger.tracef("Set execution status: Execution: %s, status: %s", logExecutionAlias(authExecutionModel), status);
if (authExecutionModel.isAuthenticatorFlow() && status == CommonClientSessionModel.ExecutionStatus.SUCCESS) {
// Trigger callbacks after flow was successfully finished
processor.getRealm().getAuthenticationExecutionsStream(authExecutionModel.getFlowId()).forEach(this::checkAuthCallback);
@ -563,8 +564,37 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
AuthenticationFlowCallback authCallback = (AuthenticationFlowCallback) createAuthenticator(authFactory);
logger.tracef("Will trigger callback '%s' after successful finish of the flow '%s'", authFactory.getId(), execution.getParentFlow());
authCallback.onParentFlowSuccess(processor.createAuthenticatorContext(execution, authCallback, null)); // no need to have executions filled
AuthenticatorUtil.setAuthCallbacksFactoryIds(processor.getAuthenticationSession(), authFactory.getId());
}
}
}
}
// This is triggered when current flow is successful due the fact that it's executions passed.
// It is opportunity to do some last "generic" checks before considering whole authentication as successful
private Response onFlowExecutionsSuccessful() {
if (flow.isTopLevel()) {
logger.debugf("Authentication successful of the top flow '%s'", flow.getAlias());
executeTopFlowSuccessCallbacks();
}
successful = true;
return null;
}
/**
* Execute callbacks defined for each {@see AuthenticationFlowCallbackFactory} class in top authentication flow if success
*/
private void executeTopFlowSuccessCallbacks() {
final AuthenticationSessionModel authSession = processor.getAuthenticationSession();
final Set<String> factoryProviderIDs = AuthenticatorUtil.getAuthCallbacksFactoryIds(authSession);
factoryProviderIDs.stream()
.filter(StringUtil::isNotBlank)
.map(id -> processor.getSession().getProvider(Authenticator.class, id))
.filter(Objects::nonNull)
.filter(AuthenticationFlowCallback.class::isInstance)
.map(AuthenticationFlowCallback.class::cast)
.forEach(AuthenticationFlowCallback::onTopFlowSuccess);
}
}

View file

@ -15,9 +15,9 @@ public interface ConditionalAuthenticatorFactory extends AuthenticatorFactory, D
@Override
default Authenticator createDisplay(KeycloakSession session, String displayType) {
if (displayType == null) return getSingleton();
if (displayType == null) return create(session);
if (!OAuth2Constants.DISPLAY_CONSOLE.equalsIgnoreCase(displayType)) return null;
return getSingleton();
return create(session);
}
ConditionalAuthenticator getSingleton();

View file

@ -20,28 +20,41 @@ package org.keycloak.authentication.authenticators.conditional;
import org.jboss.logging.Logger;
import org.keycloak.authentication.AuthenticationFlowCallback;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.AuthenticationFlowException;
import org.keycloak.authentication.AuthenticatorUtil;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.services.messages.Messages;
import org.keycloak.sessions.AuthenticationSessionModel;
public class ConditionalLoaAuthenticator implements ConditionalAuthenticator, AuthenticationFlowCallback {
public static final String LEVEL = "loa-condition-level";
public static final String STORE_IN_USER_SESSION = "loa-store-in-user-session";
private static final Logger logger = Logger.getLogger(ConditionalLoaAuthenticator.class);
private final KeycloakSession session;
public ConditionalLoaAuthenticator(KeycloakSession session) {
this.session = session;
}
@Override
public boolean matchCondition(AuthenticationFlowContext context) {
AuthenticationSessionModel authSession = context.getAuthenticationSession();
int currentLoa = AuthenticatorUtil.getCurrentLevelOfAuthentication(authSession);
int requestedLoa = AuthenticatorUtil.getRequestedLevelOfAuthentication(authSession);
Integer configuredLoa = getConfiguredLoa(context);
return (currentLoa < Constants.MINIMUM_LOA && requestedLoa < Constants.MINIMUM_LOA)
boolean result = (currentLoa < Constants.MINIMUM_LOA && requestedLoa < Constants.MINIMUM_LOA)
|| ((configuredLoa == null || currentLoa < configuredLoa) && currentLoa < requestedLoa);
logger.tracef("Checking condition '%s' : currentLoa: %d, requestedLoa: %d, configuredLoa: %d, evaluation result: %b",
context.getAuthenticatorConfig().getAlias(), currentLoa, requestedLoa, configuredLoa, result);
return result;
}
@Override
@ -58,6 +71,17 @@ public class ConditionalLoaAuthenticator implements ConditionalAuthenticator, Au
}
}
@Override
public void onTopFlowSuccess() {
AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession();
if (AuthenticatorUtil.isLevelOfAuthenticationForced(authSession) && !AuthenticatorUtil.isLevelOfAuthenticationSatisfied(authSession) && !AuthenticatorUtil.isSSOAuthentication(authSession)) {
String details = String.format("Forced level of authentication did not meet the requirements. Requested level: %d, Fulfilled level: %d",
AuthenticatorUtil.getRequestedLevelOfAuthentication(authSession), AuthenticatorUtil.getCurrentLevelOfAuthentication(authSession));
throw new AuthenticationFlowException(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR, details, Messages.ACR_NOT_FULFILLED);
}
}
private Integer getConfiguredLoa(AuthenticationFlowContext context) {
try {
return Integer.parseInt(context.getAuthenticatorConfig().getConfig().get(LEVEL));

View file

@ -20,7 +20,9 @@ package org.keycloak.authentication.authenticators.conditional;
import java.util.List;
import org.keycloak.Config;
import org.keycloak.authentication.AuthenticationFlowCallbackFactory;
import org.keycloak.authentication.Authenticator;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
@ -28,7 +30,6 @@ import org.keycloak.provider.ProviderConfigurationBuilder;
public class ConditionalLoaAuthenticatorFactory implements ConditionalAuthenticatorFactory, AuthenticationFlowCallbackFactory {
public static final String PROVIDER_ID = "conditional-level-of-authentication";
private static final ConditionalLoaAuthenticator SINGLETON = new ConditionalLoaAuthenticator();
private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = new AuthenticationExecutionModel.Requirement[]{
AuthenticationExecutionModel.Requirement.REQUIRED,
AuthenticationExecutionModel.Requirement.DISABLED
@ -50,6 +51,11 @@ public class ConditionalLoaAuthenticatorFactory implements ConditionalAuthentica
.add()
.build();
@Override
public Authenticator create(KeycloakSession session) {
return new ConditionalLoaAuthenticator(session);
}
@Override
public void init(Config.Scope config) { }
@ -101,6 +107,7 @@ public class ConditionalLoaAuthenticatorFactory implements ConditionalAuthentica
@Override
public ConditionalAuthenticator getSingleton() {
return SINGLETON;
// NOP - instance created in create() method
return null;
}
}

View file

@ -306,9 +306,16 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
Integer loa = acrLoaMap.get(acr);
return loa == null ? Integer.parseInt(acr) : loa;
} catch (NumberFormatException e) {
// this is an unknown acr, we assume in case of an essential claim a very high LoA, and a minimum LoA if not essential
return Boolean.parseBoolean(authenticationSession.getClientNote(Constants.FORCE_LEVEL_OF_AUTHENTICATION))
? Constants.MAXIMUM_LOA : Constants.MINIMUM_LOA;
// this is an unknown acr. In case of an essential claim, we directly reject authentication as we cannot met the specification requirement. Otherwise fallback to minimum LoA
boolean essential = Boolean.parseBoolean(authenticationSession.getClientNote(Constants.FORCE_LEVEL_OF_AUTHENTICATION));
if (essential) {
logger.errorf("Requested essential acr value '%s' is not a number and it is not mapped in the client mappings. Please doublecheck your client configuration or correct ACR passed in the 'claims' parameter.", acr);
event.error(Errors.INVALID_REQUEST);
throw new ErrorPageException(session, authenticationSession, Response.Status.BAD_REQUEST, Messages.INVALID_PARAMETER, OIDCLoginProtocol.CLAIMS_PARAM);
} else {
logger.warnf("Requested acr value '%s' is not a number and it is not mapped in the client mappings. Please doublecheck your client configuration or correct ACR passed in the 'claims' parameter. Ignoring passed acr", acr);
return Constants.MINIMUM_LOA;
}
}
}).min().ifPresent(loa -> authenticationSession.setClientNote(Constants.REQUESTED_LEVEL_OF_AUTHENTICATION, String.valueOf(loa)));

View file

@ -37,26 +37,30 @@ public class AcrUtils {
private static final Logger LOGGER = Logger.getLogger(AcrUtils.class);
public static List<String> getRequiredAcrValues(String claimsParam) {
return getAcrValues(claimsParam, null, false);
return getAcrValues(claimsParam, null, true);
}
public static List<String> getAcrValues(String claimsParam, String acrValuesParam) {
return getAcrValues(claimsParam, acrValuesParam, true);
return getAcrValues(claimsParam, acrValuesParam, false);
}
private static List<String> getAcrValues(String claimsParam, String acrValuesParam, boolean notEssential) {
private static List<String> getAcrValues(String claimsParam, String acrValuesParam, boolean essential) {
List<String> acrValues = new ArrayList<>();
if (acrValuesParam != null && notEssential) {
if (acrValuesParam != null && !essential) {
acrValues.addAll(Arrays.asList(acrValuesParam.split(" ")));
}
if (claimsParam != null) {
try {
ClaimsRepresentation claims = JsonSerialization.readValue(claimsParam, ClaimsRepresentation.class);
ClaimsRepresentation.ClaimValue<String> acrClaim = claims.getClaimValue(IDToken.ACR, ClaimsRepresentation.ClaimContext.ID_TOKEN, String.class);
if (acrClaim != null) {
if (notEssential || acrClaim.isEssential()) {
if (acrClaim.getValues() != null) {
acrValues.addAll(acrClaim.getValues());
if (claims == null) {
LOGGER.warnf("Invalid claims parameter. Claims parameter should be JSON");
} else {
ClaimsRepresentation.ClaimValue<String> acrClaim = claims.getClaimValue(IDToken.ACR, ClaimsRepresentation.ClaimContext.ID_TOKEN, String.class);
if (acrClaim != null) {
if (!essential || acrClaim.isEssential()) {
if (acrClaim.getValues() != null) {
acrValues.addAll(acrClaim.getValues());
}
}
}
}
@ -92,6 +96,7 @@ public class AcrUtils {
mappedLoa = Integer.parseInt(acrValue);
} catch (NumberFormatException e) {
// the acrValue cannot be mapped
LOGGER.warnf("Acr value '%s' cannot be mapped to int", acrValue);
}
}
if (mappedLoa != null && mappedLoa > maxLoa && loa >= mappedLoa) {

View file

@ -25,6 +25,7 @@ import org.keycloak.TokenVerifier.TokenTypeCheck;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.AuthenticationFlowException;
import org.keycloak.authentication.AuthenticationProcessor;
import org.keycloak.authentication.AuthenticatorUtil;
import org.keycloak.authentication.ConsoleDisplayMode;
import org.keycloak.authentication.DisplayTypeRequiredActionFactory;
import org.keycloak.authentication.InitiatedActionSupport;
@ -919,7 +920,7 @@ public class AuthenticationManager {
AuthenticatedClientSessionModel clientSession = clientSessionCtx.getClientSession();
// Update userSession note with authTime. But just if flag SSO_AUTH is not set
boolean isSSOAuthentication = "true".equals(authSession.getAuthNote(SSO_AUTH));
boolean isSSOAuthentication = AuthenticatorUtil.isSSOAuthentication(authSession);
if (isSSOAuthentication) {
clientSession.setNote(SSO_AUTH, "true");
authSession.removeAuthNote(SSO_AUTH);

View file

@ -243,6 +243,8 @@ public class Messages {
public static final String BROKER_LINKING_SESSION_EXPIRED = "brokerLinkingSessionExpired";
public static final String ACR_NOT_FULFILLED = "acrNotFulfilled";
public static final String PAGE_NOT_FOUND = "pageNotFound";
public static final String INTERNAL_SERVER_ERROR = "internalServerError";

View file

@ -0,0 +1,73 @@
/*
* Copyright 2022 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.authentication;
import org.keycloak.authentication.AuthenticationFlowCallback;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.AuthenticationFlowException;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
/**
* @author <a href="mailto:mabartos@redhat.com">Martin Bartos</a>
*/
public class CustomAuthenticationFlowCallback implements AuthenticationFlowCallback {
public static final String EXPECTED_ERROR_MESSAGE = "Custom Authentication Flow Callback message";
@Override
public void onTopFlowSuccess() {
throw new AuthenticationFlowException(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR, "detail", EXPECTED_ERROR_MESSAGE);
}
@Override
public void onParentFlowSuccess(AuthenticationFlowContext context) {
}
@Override
public void authenticate(AuthenticationFlowContext context) {
context.success();
}
@Override
public void action(AuthenticationFlowContext context) {
}
@Override
public boolean requiresUser() {
return false;
}
@Override
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
return false;
}
@Override
public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
}
@Override
public void close() {
}
}

View file

@ -0,0 +1,97 @@
/*
* Copyright 2022 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.authentication;
import org.keycloak.Config;
import org.keycloak.authentication.AuthenticationFlowCallbackFactory;
import org.keycloak.authentication.Authenticator;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
import java.util.List;
/**
* @author <a href="mailto:mabartos@redhat.com">Martin Bartos</a>
*/
public class CustomAuthenticationFlowCallbackFactory implements AuthenticationFlowCallbackFactory {
public static final String PROVIDER_ID = "custom-callback-authenticator";
private static final CustomAuthenticationFlowCallback SINGLETON = new CustomAuthenticationFlowCallback();
@Override
public Authenticator create(KeycloakSession session) {
return SINGLETON;
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public String getDisplayType() {
return "Custom callback Factory";
}
@Override
public String getReferenceCategory() {
return "callback";
}
@Override
public boolean isConfigurable() {
return false;
}
@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return new AuthenticationExecutionModel.Requirement[]{AuthenticationExecutionModel.Requirement.REQUIRED};
}
@Override
public boolean isUserSetupAllowed() {
return false;
}
@Override
public String getHelpText() {
return "Used for testing purposes of Callback factory";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return null;
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
}

View file

@ -23,4 +23,5 @@ org.keycloak.testsuite.authentication.ExpectedParamAuthenticatorFactory
org.keycloak.testsuite.authentication.PushButtonAuthenticatorFactory
org.keycloak.testsuite.forms.UsernameOnlyAuthenticator
org.keycloak.testsuite.authentication.ConditionalUserAttributeValueFactory
org.keycloak.testsuite.authentication.SetUserAttributeAuthenticatorFactory
org.keycloak.testsuite.authentication.SetUserAttributeAuthenticatorFactory
org.keycloak.testsuite.authentication.CustomAuthenticationFlowCallbackFactory

View file

@ -1648,10 +1648,14 @@ public class OAuthClient {
}
public OAuthClient claims(ClaimsRepresentation claims) {
try {
this.claims = URLEncoder.encode(JsonSerialization.writeValueAsString(claims), "UTF-8");
} catch (IOException ioe) {
throw new RuntimeException(ioe);
if (claims == null) {
this.claims = null;
} else {
try {
this.claims = URLEncoder.encode(JsonSerialization.writeValueAsString(claims), "UTF-8");
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
}
return this;
}

View file

@ -228,6 +228,9 @@ public class ProvidersTest extends AbstractAuthenticationTest {
addProviderInfo(result, "user-session-limits", "User session count limiter",
"Configures how many concurrent sessions a single user is allowed to create for this realm and/or client");
addProviderInfo(result, "custom-callback-authenticator", "Custom callback Factory",
"Used for testing purposes of Callback factory");
return result;
}

View file

@ -0,0 +1,100 @@
/*
* Copyright 2022 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.forms;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Test;
import org.keycloak.authentication.authenticators.access.AllowAccessAuthenticatorFactory;
import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory;
import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticator;
import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.testsuite.authentication.CustomAuthenticationFlowCallback;
import org.keycloak.testsuite.authentication.CustomAuthenticationFlowCallbackFactory;
import org.keycloak.testsuite.pages.ErrorPage;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.util.FlowUtil;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.REMOTE;
/**
* @author <a href="mailto:mabartos@redhat.com">Martin Bartos</a>
*/
@AuthServerContainerExclude(REMOTE)
public class AuthenticationFlowCallbackProviderTest extends AbstractTestRealmKeycloakTest {
@Page
protected LoginPage loginPage;
@Page
protected ErrorPage errorPage;
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
}
@Test
public void loaEssentialNonExisting() {
setBrowserFlow();
LevelOfAssuranceFlowTest.openLoginFormWithAcrClaim(oauth, true, "4");
loginPage.assertCurrent();
loginPage.login("test-user@localhost", "password");
errorPage.assertCurrent();
assertThat(errorPage.getError(), is("Authentication requirements not fulfilled"));
}
@Test
public void errorWithCustomProvider() {
setBrowserFlow();
LevelOfAssuranceFlowTest.openLoginFormWithAcrClaim(oauth, true, "1");
loginPage.assertCurrent();
loginPage.login("test-user@localhost", "password");
errorPage.assertCurrent();
assertThat(errorPage.getError(), is(CustomAuthenticationFlowCallback.EXPECTED_ERROR_MESSAGE));
}
protected void setBrowserFlow() {
testingClient.server("test").run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow("newFlow"));
testingClient.server("test").run(session -> FlowUtil.inCurrentRealm(session)
.selectFlow("newFlow")
.inForms(forms -> forms
.clear()
.addSubFlowExecution(AuthenticationExecutionModel.Requirement.CONDITIONAL, subflow -> subflow
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, UsernamePasswordFormFactory.PROVIDER_ID)
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID,
config -> {
config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "1");
config.getConfig().put(ConditionalLoaAuthenticator.STORE_IN_USER_SESSION, "true");
})
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, AllowAccessAuthenticatorFactory.PROVIDER_ID)
)
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, CustomAuthenticationFlowCallbackFactory.PROVIDER_ID)
)
.defineAsBrowserFlow() // Activate this new flow
);
}
}

View file

@ -49,8 +49,10 @@ import org.keycloak.testsuite.pages.LoginUsernameOnlyPage;
import org.keycloak.testsuite.pages.PasswordPage;
import org.keycloak.testsuite.pages.PushTheButtonPage;
import org.keycloak.testsuite.util.FlowUtil;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.util.JsonSerialization;
import static org.hamcrest.CoreMatchers.is;
import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.REMOTE;
/**
@ -179,7 +181,6 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
// doing step-up authentication to level 2
openLoginFormWithAcrClaim(true, "gold");
authenticateWithPassword();
authenticateWithButton();
assertLoggedInWithAcr("gold");
// step-up to level 3 needs password authentication because level 2 is not stored in user session
openLoginFormWithAcrClaim(true, "3");
@ -188,11 +189,22 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
assertLoggedInWithAcr("3");
}
@Test
public void stepupToUnknownEssentialAcrFails() {
openLoginFormWithAcrClaim(true, "silver");
authenticateWithUsername();
assertLoggedInWithAcr("silver");
// step-up to unknown acr
openLoginFormWithAcrClaim(true, "uranium");
assertErrorPage("Invalid parameter: claims");
}
@Test
public void reauthenticationWithNoAcr() {
openLoginFormWithAcrClaim(true, "silver");
authenticateWithUsername();
assertLoggedInWithAcr("silver");
oauth.claims(null);
oauth.openLoginForm();
assertLoggedInWithAcr("0");
}
@ -215,6 +227,15 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
assertLoggedInWithAcr("0");
}
@Test
public void essentialClaimNotReachedFails() {
openLoginFormWithAcrClaim(true, "4");
authenticateWithUsername();
authenticateWithPassword();
authenticateWithButton();
assertErrorPage("Authentication requirements not fulfilled");
}
@Test
public void optionalClaimNotReachedSucceeds() {
openLoginFormWithAcrClaim(false, "4");
@ -226,6 +247,12 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
assertLoggedInWithAcr("gold");
}
@Test
public void essentialUnknownClaimFails() {
openLoginFormWithAcrClaim(true, "uranium");
assertErrorPage("Invalid parameter: claims");
}
@Test
public void optionalUnknownClaimSucceeds() {
openLoginFormWithAcrClaim(false, "iron");
@ -259,13 +286,17 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
assertLoggedInWithAcr("gold");
}
private void openLoginFormWithAcrClaim(boolean essential, String... acrValues) {
public void openLoginFormWithAcrClaim(boolean essential, String... acrValues) {
openLoginFormWithAcrClaim(oauth, essential, acrValues);
}
public static void openLoginFormWithAcrClaim(OAuthClient oauth, boolean essential, String... acrValues) {
ClaimsRepresentation.ClaimValue<String> acrClaim = new ClaimsRepresentation.ClaimValue<>();
acrClaim.setEssential(essential);
acrClaim.setValues(Arrays.asList(acrValues));
ClaimsRepresentation claims = new ClaimsRepresentation();
claims.setIdTokenClaims(Collections.singletonMap("acr", acrClaim));
claims.setIdTokenClaims(Collections.singletonMap(IDToken.ACR, acrClaim));
oauth.claims(claims);
oauth.openLoginForm();
@ -291,4 +322,9 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
IDToken idToken = sendTokenRequestAndGetIDToken(loginEvent);
Assert.assertEquals(acr, idToken.getAcr());
}
private void assertErrorPage(String expectedError) {
Assert.assertThat(true, is(errorPage.isCurrent()));
Assert.assertEquals(expectedError, errorPage.getError());
}
}

View file

@ -0,0 +1,113 @@
/*
* Copyright 2022 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.util;
import org.hamcrest.Matchers;
import org.junit.Test;
import org.keycloak.authentication.AuthenticatorUtil;
import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import java.util.Set;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.notNullValue;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.REMOTE;
/**
* @author <a href="mailto:mabartos@redhat.com">Martin Bartos</a>
*/
@AuthServerContainerExclude(REMOTE)
public class AuthenticatorUtilTest extends AbstractTestRealmKeycloakTest {
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
}
@Test
public void variousFactoryProviders() {
testingClient.server().run(session -> {
RealmModel realm = session.realms().getRealm(TEST_REALM_NAME);
assertThat(realm, notNullValue());
ClientModel client = realm.getClientByClientId("test-app");
assertThat(client, notNullValue());
AuthenticationSessionModel authSession = session.authenticationSessions().createRootAuthenticationSession(realm)
.createAuthenticationSession(client);
assertThat(authSession, notNullValue());
Set<String> callbacksFactories = AuthenticatorUtil.getAuthCallbacksFactoryIds(authSession);
assertThat(callbacksFactories, notNullValue());
assertThat(callbacksFactories, Matchers.empty());
AuthenticatorUtil.setAuthCallbacksFactoryIds(authSession, "factory1");
callbacksFactories = AuthenticatorUtil.getAuthCallbacksFactoryIds(authSession);
assertThat(callbacksFactories, notNullValue());
assertThat(callbacksFactories.size(), is(1));
String note = authSession.getAuthNote(AuthenticatorUtil.CALLBACKS_FACTORY_IDS_NOTE);
assertThat(note, notNullValue());
assertThat(note, is("factory1"));
AuthenticatorUtil.setAuthCallbacksFactoryIds(authSession, "factory2");
callbacksFactories = AuthenticatorUtil.getAuthCallbacksFactoryIds(authSession);
assertThat(callbacksFactories, notNullValue());
assertThat(callbacksFactories.size(), is(2));
note = authSession.getAuthNote(AuthenticatorUtil.CALLBACKS_FACTORY_IDS_NOTE);
assertThat(note, notNullValue());
assertThat(note, is("factory1" + Constants.CFG_DELIMITER + "factory2"));
AuthenticatorUtil.setAuthCallbacksFactoryIds(authSession, "factory1");
callbacksFactories = AuthenticatorUtil.getAuthCallbacksFactoryIds(authSession);
assertThat(callbacksFactories, notNullValue());
assertThat(callbacksFactories.size(), is(2));
note = authSession.getAuthNote(AuthenticatorUtil.CALLBACKS_FACTORY_IDS_NOTE);
assertThat(note, notNullValue());
assertThat(note, is("factory1" + Constants.CFG_DELIMITER + "factory2"));
AuthenticatorUtil.setAuthCallbacksFactoryIds(authSession, "");
callbacksFactories = AuthenticatorUtil.getAuthCallbacksFactoryIds(authSession);
assertThat(callbacksFactories, notNullValue());
assertThat(callbacksFactories.size(), is(2));
note = authSession.getAuthNote(AuthenticatorUtil.CALLBACKS_FACTORY_IDS_NOTE);
assertThat(note, notNullValue());
assertThat(note, is("factory1" + Constants.CFG_DELIMITER + "factory2"));
AuthenticatorUtil.setAuthCallbacksFactoryIds(authSession, null);
callbacksFactories = AuthenticatorUtil.getAuthCallbacksFactoryIds(authSession);
assertThat(callbacksFactories, notNullValue());
assertThat(callbacksFactories.size(), is(2));
note = authSession.getAuthNote(AuthenticatorUtil.CALLBACKS_FACTORY_IDS_NOTE);
assertThat(note, notNullValue());
assertThat(note, is("factory1" + Constants.CFG_DELIMITER + "factory2"));
});
}
}

View file

@ -369,6 +369,7 @@ alreadyLoggedIn=You are already logged in.
differentUserAuthenticated=You are already authenticated as different user ''{0}'' in this session. Please sign out first.
brokerLinkingSessionExpired=Requested broker account linking, but current session is no longer valid.
proceedWithAction=&raquo; Click here to proceed
acrNotFulfilled=Authentication requirements not fulfilled
requiredAction.CONFIGURE_TOTP=Configure OTP
requiredAction.terms_and_conditions=Terms and Conditions