From a06f50b4ef8d6674341903b68daa9dea41861ddc Mon Sep 17 00:00:00 2001 From: Sven-Torben Janus Date: Tue, 7 Feb 2023 18:30:42 +0100 Subject: [PATCH 1/2] Support to enforce LoA in authentication flow (Step-up) Closes #16884 --- .../browser/ForceLoAAuthenticator.java | 87 +++++++++++++++++++ .../browser/ForceLoAAuthenticatorConfig.java | 33 +++++++ ...ForceLoAAuthenticatorConfigProperties.java | 26 ++++++ .../browser/ForceLoAAuthenticatorFactory.java | 79 +++++++++++++++++ .../ConditionalClientIdAuthenticator.java | 41 +++++++++ ...nditionalClientIdAuthenticatorFactory.java | 82 +++++++++++++++++ .../ConditionalClientIdConfig.java | 47 ++++++++++ .../ConditionalClientIdConfigProperties.java | 38 ++++++++ ...ycloak.authentication.AuthenticatorFactory | 2 + .../admin/authentication/ProvidersTest.java | 6 ++ 10 files changed, 441 insertions(+) create mode 100644 services/src/main/java/org/keycloak/authentication/authenticators/browser/ForceLoAAuthenticator.java create mode 100644 services/src/main/java/org/keycloak/authentication/authenticators/browser/ForceLoAAuthenticatorConfig.java create mode 100644 services/src/main/java/org/keycloak/authentication/authenticators/browser/ForceLoAAuthenticatorConfigProperties.java create mode 100644 services/src/main/java/org/keycloak/authentication/authenticators/browser/ForceLoAAuthenticatorFactory.java create mode 100644 services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalClientIdAuthenticator.java create mode 100644 services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalClientIdAuthenticatorFactory.java create mode 100644 services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalClientIdConfig.java create mode 100644 services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalClientIdConfigProperties.java diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/ForceLoAAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/ForceLoAAuthenticator.java new file mode 100644 index 000000000000..9df89475a298 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/ForceLoAAuthenticator.java @@ -0,0 +1,87 @@ +package org.keycloak.authentication.authenticators.browser; + +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.authenticators.util.AcrStore; +import org.keycloak.models.ClientModel; +import org.keycloak.models.Constants; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.protocol.oidc.utils.AcrUtils; +import org.keycloak.sessions.AuthenticationSessionModel; + +import java.util.List; +import java.util.Map; + +import static org.keycloak.models.Constants.NO_LOA; + +public class ForceLoAAuthenticator implements Authenticator { + + @Override + public void authenticate(AuthenticationFlowContext authenticationFlowContext) { + int configuredMinLoa = new ForceLoAAuthenticatorConfig( + authenticationFlowContext.getAuthenticatorConfig()).levelOfAuthentication(); + + ClientModel client = authenticationFlowContext.getAuthenticationSession().getClient(); + + int maxDefaultLoa = getMaxDefaultLoa(client); + int enforcedLoA = Math.max(configuredMinLoa, maxDefaultLoa); + + AuthenticationSessionModel authenticationSession = authenticationFlowContext.getAuthenticationSession(); + AcrStore acrStore = new AcrStore(authenticationSession); + int requestedLevelOfAuthentication = acrStore.getRequestedLevelOfAuthentication(); + if (requestedLevelOfAuthentication < enforcedLoA) { + authenticationSession.setClientNote(Constants.REQUESTED_LEVEL_OF_AUTHENTICATION, + String.valueOf(enforcedLoA)); + authenticationSession.setClientNote(Constants.FORCE_LEVEL_OF_AUTHENTICATION, Boolean.TRUE.toString()); + } + authenticationFlowContext.success(); + } + + private int getMaxDefaultLoa(ClientModel client) { + int defaultLoa = NO_LOA; + List defaultAcrValues = AcrUtils.getDefaultAcrValues(client); + Map acrToLoaMap = AcrUtils.getAcrLoaMap(client); + if (acrToLoaMap.isEmpty()) { + acrToLoaMap = AcrUtils.getAcrLoaMap(client.getRealm()); + } + for (String configuredAcr : defaultAcrValues) { + int loa; + if (acrToLoaMap.containsKey(configuredAcr)) { + loa = acrToLoaMap.get(configuredAcr); + } else { + try { + loa = Integer.parseInt(configuredAcr); + } catch(NumberFormatException ex) { + loa = NO_LOA; + } + } + defaultLoa = Math.max(defaultLoa, loa); + } + return defaultLoa; + } + + @Override + public void action(AuthenticationFlowContext authenticationFlowContext) { + } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession keycloakSession, RealmModel realmModel, UserModel userModel) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession keycloakSession, RealmModel realmModel, UserModel userModel) { + } + + @Override + public void close() { + + } +} diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/ForceLoAAuthenticatorConfig.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/ForceLoAAuthenticatorConfig.java new file mode 100644 index 000000000000..4f340615a195 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/ForceLoAAuthenticatorConfig.java @@ -0,0 +1,33 @@ +package org.keycloak.authentication.authenticators.browser; + +import org.keycloak.models.AuthenticatorConfigModel; + +import java.util.Map; +import java.util.Optional; + +import static com.google.common.base.Strings.emptyToNull; +import static org.keycloak.models.Constants.NO_LOA; + +final class ForceLoAAuthenticatorConfig { + + static final String MIN_LOA = "minLoA"; + + private final AuthenticatorConfigModel authenticatorConfigModel; + + ForceLoAAuthenticatorConfig(AuthenticatorConfigModel configModel) { + this.authenticatorConfigModel = configModel; + } + + int levelOfAuthentication() { + return getConfigMap() + .map(config -> config.getOrDefault(MIN_LOA, String.valueOf(NO_LOA))) + .map(str -> Optional.ofNullable(emptyToNull(str)).orElse(String.valueOf(NO_LOA))) + .map(Integer::parseInt) + .orElse(NO_LOA); + } + + private Optional> getConfigMap() { + return Optional.ofNullable(authenticatorConfigModel) + .map(AuthenticatorConfigModel::getConfig); + } +} diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/ForceLoAAuthenticatorConfigProperties.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/ForceLoAAuthenticatorConfigProperties.java new file mode 100644 index 000000000000..4038b85ef79e --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/ForceLoAAuthenticatorConfigProperties.java @@ -0,0 +1,26 @@ +package org.keycloak.authentication.authenticators.browser; + +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; + +import java.util.List; + +import static java.util.Collections.emptyList; +import static org.keycloak.authentication.authenticators.browser.ForceLoAAuthenticatorConfig.MIN_LOA; +import static org.keycloak.provider.ProviderConfigProperty.STRING_TYPE; + +final class ForceLoAAuthenticatorConfigProperties { + + private static final ProviderConfigProperty MIN_LOA_PROPERTY = new ProviderConfigProperty( + MIN_LOA, + "Minimum Level of Authenticaton (LoA)", + "The minimum level of authentication enforced by this authenticator. If a client request a LoA greater than this value, the requested LoA will be used. Otherwise, this configured value will be used.", + STRING_TYPE, + emptyList(), + false); + + static final List CONFIG_PROPERTIES = ProviderConfigurationBuilder.create() + .property(MIN_LOA_PROPERTY) + .build(); + +} diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/ForceLoAAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/ForceLoAAuthenticatorFactory.java new file mode 100644 index 000000000000..dc474dec53dc --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/ForceLoAAuthenticatorFactory.java @@ -0,0 +1,79 @@ +package org.keycloak.authentication.authenticators.browser; + +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.List; + +import static org.keycloak.authentication.authenticators.browser.ForceLoAAuthenticatorConfigProperties.CONFIG_PROPERTIES; +import static org.keycloak.models.AuthenticationExecutionModel.Requirement.DISABLED; +import static org.keycloak.models.AuthenticationExecutionModel.Requirement.REQUIRED; + +public class ForceLoAAuthenticatorFactory implements AuthenticatorFactory { + + private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = new AuthenticationExecutionModel.Requirement[]{REQUIRED, DISABLED}; + + private static final String PROVIDER_ID = "auth-force-loa"; + + @Override + public String getDisplayType() { + return "Force Level of Authentication (LoA)"; + } + + @Override + public String getReferenceCategory() { + return "Authorization"; + } + + @Override + public boolean isConfigurable() { + return true; + } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public String getHelpText() { + return "This authenticator forces the minimum level of authentication (LoA) if a client does not request a LoA or the requested LoA is less than the configured LoA."; + } + + @Override + public List getConfigProperties() { + return CONFIG_PROPERTIES; + } + + @Override + public Authenticator create(KeycloakSession keycloakSession) { + return new ForceLoAAuthenticator(); + } + + @Override + public void init(Config.Scope scope) { + } + + @Override + public void postInit(KeycloakSessionFactory keycloakSessionFactory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return PROVIDER_ID; + } +} diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalClientIdAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalClientIdAuthenticator.java new file mode 100644 index 000000000000..d828bb827a06 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalClientIdAuthenticator.java @@ -0,0 +1,41 @@ +package org.keycloak.authentication.authenticators.conditional; + +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; + +final class ConditionalClientIdAuthenticator implements ConditionalAuthenticator { + + ConditionalClientIdAuthenticator() { + } + + @Override + public boolean matchCondition(AuthenticationFlowContext authenticationFlowContext) { + ClientModel client = authenticationFlowContext.getAuthenticationSession().getClient(); + ConditionalClientIdConfig config = new ConditionalClientIdConfig( + authenticationFlowContext.getAuthenticatorConfig()); + String clientId = client.getClientId(); + boolean matches = config.getClientIds().contains(clientId); + return config.isNegateOutput() != matches; + } + + @Override + public void action(AuthenticationFlowContext authenticationFlowContext) { + } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + } + + @Override + public void close() { + } + +} diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalClientIdAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalClientIdAuthenticatorFactory.java new file mode 100644 index 000000000000..3311b081af89 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalClientIdAuthenticatorFactory.java @@ -0,0 +1,82 @@ +package org.keycloak.authentication.authenticators.conditional; + +import org.jboss.logging.Logger; +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.List; + +import static org.keycloak.models.AuthenticationExecutionModel.Requirement.DISABLED; +import static org.keycloak.models.AuthenticationExecutionModel.Requirement.REQUIRED; + +public final class ConditionalClientIdAuthenticatorFactory implements AuthenticatorFactory { + + private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = new AuthenticationExecutionModel.Requirement[]{REQUIRED, DISABLED}; + + private static final String PROVIDER_ID = "conditional-client-id"; + + private Config.Scope config; + + @Override + public String getDisplayType() { + return "Condition - Client id"; + } + + @Override + public String getReferenceCategory() { + return "Authorization"; + } + + @Override + public boolean isConfigurable() { + return true; + } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public String getHelpText() { + return "Flow is executed only if client id matches configured ids"; + } + + @Override + public List getConfigProperties() { + return ConditionalClientIdConfigProperties.CONFIG_PROPERTIES; + } + + @Override + public Authenticator create(KeycloakSession session) { + return new ConditionalClientIdAuthenticator(); + } + + @Override + public void init(Config.Scope config) { + this.config = config; + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return PROVIDER_ID; + } +} diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalClientIdConfig.java b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalClientIdConfig.java new file mode 100644 index 000000000000..0d14f17e40cf --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalClientIdConfig.java @@ -0,0 +1,47 @@ +package org.keycloak.authentication.authenticators.conditional; + +import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.Constants; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static java.util.Collections.emptyList; + +final class ConditionalClientIdConfig { + + static final String CONDITIONAL_CLIENT_IDS = "clientIds"; + static final String CONF_NEGATE = "negate"; + + private final AuthenticatorConfigModel authenticatorConfigModel; + + ConditionalClientIdConfig(AuthenticatorConfigModel configModel) { + this.authenticatorConfigModel = configModel; + } + + List getClientIds() { + List clientIds = new ArrayList<>(getConfigMap() + .map(config -> Arrays.asList(Constants.CFG_DELIMITER_PATTERN.split( + Optional.ofNullable( + config.getOrDefault(ConditionalClientIdConfig.CONDITIONAL_CLIENT_IDS, "") + ).orElse("").trim()))) + .orElse(emptyList())); + clientIds.remove(""); + return clientIds; + } + + boolean isNegateOutput() { + return getConfigMap() + .map(config -> Boolean.parseBoolean( + config.getOrDefault(ConditionalClientIdConfig.CONF_NEGATE, Boolean.FALSE.toString()))) + .orElse(false); + } + + private Optional> getConfigMap() { + return Optional.ofNullable(authenticatorConfigModel) + .map(AuthenticatorConfigModel::getConfig); + } +} diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalClientIdConfigProperties.java b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalClientIdConfigProperties.java new file mode 100644 index 000000000000..c9b1d58f3dee --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalClientIdConfigProperties.java @@ -0,0 +1,38 @@ +package org.keycloak.authentication.authenticators.conditional; + +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; + +import java.util.List; + +import static java.util.Collections.emptyList; +import static org.keycloak.authentication.authenticators.conditional.ConditionalClientIdConfig.CONDITIONAL_CLIENT_IDS; +import static org.keycloak.authentication.authenticators.conditional.ConditionalClientIdConfig.CONF_NEGATE; +import static org.keycloak.provider.ProviderConfigProperty.BOOLEAN_TYPE; +import static org.keycloak.provider.ProviderConfigProperty.MULTIVALUED_STRING_TYPE; + +final class ConditionalClientIdConfigProperties { + + private static final ProviderConfigProperty CLIENT_IDS_PROPERTY = new ProviderConfigProperty( + CONDITIONAL_CLIENT_IDS, + "Client Ids", + "Client ids that match the condition.", + MULTIVALUED_STRING_TYPE, + emptyList(), + false); + + private static final ProviderConfigProperty NEGATE_PROPERTY = new ProviderConfigProperty( + CONF_NEGATE, + "Negate output", + "Apply a NOT to the check result. When this is true, then the condition will evaluate to true just if clientId is NOT in the specified list of client ids. When this is false, the condition will evaluate to true just if the client id is in the list of specified client ids.", + BOOLEAN_TYPE, + false, + false); + + + static final List CONFIG_PROPERTIES = ProviderConfigurationBuilder.create() + .property(CLIENT_IDS_PROPERTY) + .property(NEGATE_PROPERTY) + .build(); + +} diff --git a/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory index 999a7696b605..1b5b4d7ffab0 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory @@ -54,3 +54,5 @@ org.keycloak.authentication.authenticators.access.DenyAccessAuthenticatorFactory org.keycloak.authentication.authenticators.access.AllowAccessAuthenticatorFactory org.keycloak.authentication.authenticators.sessionlimits.UserSessionLimitsAuthenticatorFactory org.keycloak.authentication.authenticators.browser.RecoveryAuthnCodesFormAuthenticatorFactory +org.keycloak.authentication.authenticators.conditional.ConditionalClientIdAuthenticatorFactory +org.keycloak.authentication.authenticators.browser.ForceLoAAuthenticatorFactory diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java index f064b3c2437e..6665190bfee6 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java @@ -223,6 +223,12 @@ private List> expectedAuthProviders() { addProviderInfo(result, "custom-callback-authenticator", "Custom callback Factory", "Used for testing purposes of Callback factory"); + addProviderInfo(result, "auth-force-loa", "Force Level of Authentication (LoA)", + "This authenticator forces the minimum level of authentication (LoA) if a client does not request a LoA or the requested LoA is less than the configured LoA."); + + addProviderInfo(result, "conditional-client-id", "Condition - Client id", + "Flow is executed only if client id matches configured ids"); + return result; } From 75689bfd30091b465956234a2e54cebde36d9aba Mon Sep 17 00:00:00 2001 From: Sven-Torben Janus Date: Tue, 14 Mar 2023 16:03:36 +0100 Subject: [PATCH 2/2] Add client policy to enforce LoA (Step-Up) per client Closes #16884 --- .../representations/ClaimsRepresentation.java | 4 + .../oidc/endpoints/AuthorizationEndpoint.java | 27 +++-- .../request/AuthorizationEndpointRequest.java | 9 ++ .../executor/LoAEnforcerExecutor.java | 107 ++++++++++++++++++ .../executor/LoAEnforcerExecutorFactory.java | 60 ++++++++++ ...ecutor.ClientPolicyExecutorProviderFactory | 3 +- 6 files changed, 202 insertions(+), 8 deletions(-) create mode 100644 services/src/main/java/org/keycloak/services/clientpolicy/executor/LoAEnforcerExecutor.java create mode 100644 services/src/main/java/org/keycloak/services/clientpolicy/executor/LoAEnforcerExecutorFactory.java diff --git a/core/src/main/java/org/keycloak/representations/ClaimsRepresentation.java b/core/src/main/java/org/keycloak/representations/ClaimsRepresentation.java index a6f7a6a523b9..09cd7b27c0ad 100644 --- a/core/src/main/java/org/keycloak/representations/ClaimsRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/ClaimsRepresentation.java @@ -108,6 +108,10 @@ public ClaimValue getClaimValue(String claimName, Claim } } + public void addIdTokenClaim(String claimName, ClaimValue claimValue) { + idTokenClaims.put(claimName, claimValue); + } + public enum ClaimContext { ID_TOKEN, USERINFO } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java index 23d3e3bc0d52..a1866875acd8 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java @@ -35,8 +35,8 @@ import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest; import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequestParserProcessor; -import org.keycloak.protocol.oidc.utils.AcrUtils; import org.keycloak.protocol.oidc.grants.device.endpoints.DeviceEndpoint; +import org.keycloak.protocol.oidc.utils.AcrUtils; import org.keycloak.protocol.oidc.utils.OIDCRedirectUriBuilder; import org.keycloak.protocol.oidc.utils.OIDCResponseMode; import org.keycloak.protocol.oidc.utils.OIDCResponseType; @@ -44,6 +44,7 @@ import org.keycloak.services.Urls; import org.keycloak.services.clientpolicy.ClientPolicyException; import org.keycloak.services.clientpolicy.context.AuthorizationRequestContext; +import org.keycloak.services.clientpolicy.executor.LoAEnforcerExecutor; import org.keycloak.services.messages.Messages; import org.keycloak.services.resources.LoginActionsService; import org.keycloak.services.util.CacheControlUtil; @@ -57,11 +58,10 @@ import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; import java.util.List; import java.util.Map; +import java.util.OptionalInt; +import java.util.stream.IntStream; /** * @author Stian Thorgersen @@ -300,19 +300,23 @@ private void updateAuthenticationSession() { Map acrLoaMap = AcrUtils.getAcrLoaMap(authenticationSession.getClient()); List acrValues = AcrUtils.getRequiredAcrValues(request.getClaims()); + if (request.getContextData().containsKey(LoAEnforcerExecutor.CONTEXT_DATA_ENFORCED_ACR)) { + acrValues.add(request.getContextData().get(LoAEnforcerExecutor.CONTEXT_DATA_ENFORCED_ACR).toString()); + } + if (acrValues.isEmpty()) { acrValues = AcrUtils.getAcrValues(request.getClaims(), request.getAcr(), authenticationSession.getClient()); } else { authenticationSession.setClientNote(Constants.FORCE_LEVEL_OF_AUTHENTICATION, "true"); } - acrValues.stream().mapToInt(acr -> { + boolean essential = Boolean.parseBoolean(authenticationSession.getClientNote(Constants.FORCE_LEVEL_OF_AUTHENTICATION)); + IntStream requestedLoas = acrValues.stream().mapToInt(acr -> { try { Integer loa = acrLoaMap.get(acr); return loa == null ? Integer.parseInt(acr) : loa; } catch (NumberFormatException e) { // 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 ACR-To-Loa mappings of realm or client. Please doublecheck ACR-to-LOA mapping or correct ACR passed in the 'claims' parameter.", acr); event.error(Errors.INVALID_REQUEST); @@ -322,7 +326,16 @@ private void updateAuthenticationSession() { return Constants.MINIMUM_LOA; } } - }).min().ifPresent(loa -> authenticationSession.setClientNote(Constants.REQUESTED_LEVEL_OF_AUTHENTICATION, String.valueOf(loa))); + }); + + OptionalInt requestedLoa; + if (essential) { + requestedLoa = requestedLoas.max(); + } else { + requestedLoa = requestedLoas.min(); + } + + requestedLoa.ifPresent(loa ->authenticationSession.setClientNote(Constants.REQUESTED_LEVEL_OF_AUTHENTICATION, String.valueOf(loa))); if (request.getAdditionalReqParams() != null) { for (String paramName : request.getAdditionalReqParams().keySet()) { diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java index ae2f24596885..eb05a9c4b9fc 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java @@ -45,6 +45,7 @@ public class AuthorizationEndpointRequest { String claims; String uiLocales; Map additionalReqParams = new HashMap<>(); + Map contextData = new HashMap<>(); // https://tools.ietf.org/html/rfc7636#section-6.1 String codeChallenge; @@ -147,4 +148,12 @@ public AuthorizationRequestContext getAuthorizationRequestContext() { public void setAuthorizationRequestContext(AuthorizationRequestContext authorizationRequestContext) { this.authorizationRequestContext = authorizationRequestContext; } + + public Map getContextData() { + return contextData; + } + + public void addContextData(String key, Object value) { + contextData.put(key, value); + } } diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/LoAEnforcerExecutor.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/LoAEnforcerExecutor.java new file mode 100644 index 000000000000..f9b161fba3e1 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/LoAEnforcerExecutor.java @@ -0,0 +1,107 @@ +package org.keycloak.services.clientpolicy.executor; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oidc.utils.AcrUtils; +import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation; +import org.keycloak.services.clientpolicy.ClientPolicyContext; +import org.keycloak.services.clientpolicy.ClientPolicyException; +import org.keycloak.services.clientpolicy.context.AuthorizationRequestContext; + +import java.util.List; +import java.util.Map; + +import static org.keycloak.services.clientpolicy.executor.LoAEnforcerExecutorFactory.MIN_ACR; +import static org.keycloak.services.clientpolicy.executor.LoAEnforcerExecutorFactory.PROVIDER_ID; +import static org.keycloak.services.clientpolicy.executor.LoAEnforcerExecutorFactory.USE_CLIENT_ACRS; + +public class LoAEnforcerExecutor implements ClientPolicyExecutorProvider { + + public static final String CONTEXT_DATA_ENFORCED_ACR = "ENFORCED_ACR"; + + private final KeycloakSession session; + private Configuration configuration; + + public LoAEnforcerExecutor(KeycloakSession session) { + this.session = session; + } + + @Override + public void setupConfiguration(Configuration config) { + this.configuration = config; + } + + @Override + public Class getExecutorConfigurationClass() { + return Configuration.class; + } + + public static class Configuration extends ClientPolicyExecutorConfigurationRepresentation { + + @JsonProperty(MIN_ACR) + protected String minAcr; + + public String getMinAcr() { + return minAcr; + } + + public void setMinAcr(String minAcr) { + this.minAcr = minAcr; + } + + @JsonProperty(USE_CLIENT_ACRS) + protected Boolean useClientAcrs; + + public Boolean isUseClientAcrs() { + return useClientAcrs; + } + + public void setUseClientAcrs(Boolean useClientAcrs) { + this.useClientAcrs = useClientAcrs; + } + } + + @Override + public String getProviderId() { + return PROVIDER_ID; + } + + @Override + public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException { + switch (context.getEvent()) { + case AUTHORIZATION_REQUEST: + AuthorizationRequestContext authorizationRequestContext = (AuthorizationRequestContext) context; + executeOnAuthorizationRequest(authorizationRequestContext); + return; + default: + return; + } + } + + private void executeOnAuthorizationRequest(AuthorizationRequestContext authorizationRequestContext) { + String enforcedAcr = getEnforcedAcr(); + authorizationRequestContext.getAuthorizationEndpointRequest().addContextData(CONTEXT_DATA_ENFORCED_ACR, enforcedAcr); + } + + private String getEnforcedAcr() { + String enforcedAcr; + if (configuration.useClientAcrs) { + ClientModel client = session.getContext().getClient(); + List defaultAcrValues = AcrUtils.getDefaultAcrValues(client); + Map acrToLoaMap = AcrUtils.getAcrLoaMap(client); + if (acrToLoaMap.isEmpty()) { + acrToLoaMap = AcrUtils.getAcrLoaMap(client.getRealm()); + } + enforcedAcr = acrToLoaMap.entrySet().stream() + .filter(it -> defaultAcrValues.contains(it.getKey())) + .max(Map.Entry.comparingByValue()) + .map(Map.Entry::getKey) + .orElse(configuration.getMinAcr()); + } else { + enforcedAcr = configuration.getMinAcr(); + } + return enforcedAcr; + } + +} diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/LoAEnforcerExecutorFactory.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/LoAEnforcerExecutorFactory.java new file mode 100644 index 000000000000..4740fd27826c --- /dev/null +++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/LoAEnforcerExecutorFactory.java @@ -0,0 +1,60 @@ +package org.keycloak.services.clientpolicy.executor; + +import org.keycloak.Config.Scope; +import org.keycloak.models.Constants; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.Arrays; +import java.util.List; + +public class LoAEnforcerExecutorFactory implements ClientPolicyExecutorProviderFactory { + + public static final String PROVIDER_ID = "loa-enforcer"; + + public static final String MIN_ACR = "min-acr"; + + private static final ProviderConfigProperty MIN_ACR_PROPERTY = new ProviderConfigProperty( + MIN_ACR, "Minimum ACR", "The minimum ACR that should be enforced.", ProviderConfigProperty.STRING_TYPE, + Constants.MINIMUM_LOA); + + public static final String USE_CLIENT_ACRS = "use-client-acrs"; + + private static final ProviderConfigProperty USE_CLIENT_ACRS_PROPERTY = new ProviderConfigProperty( + USE_CLIENT_ACRS, "Use client's default ACRs", "Whether to enforce client's default ACRs or not.", ProviderConfigProperty.BOOLEAN_TYPE, + false); + + @Override + public ClientPolicyExecutorProvider create(KeycloakSession session) { + return new LoAEnforcerExecutor(session); + } + + @Override + public void init(Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getHelpText() { + return "It makes the client enforce a certain level of authentication."; + } + + @Override + public List getConfigProperties() { + return Arrays.asList(MIN_ACR_PROPERTY, USE_CLIENT_ACRS_PROPERTY); + } + +} diff --git a/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory index be75d59bd352..316ccea7d0a7 100644 --- a/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory @@ -18,4 +18,5 @@ org.keycloak.services.clientpolicy.executor.RejectResourceOwnerPasswordCredentia org.keycloak.services.clientpolicy.executor.ClientSecretRotationExecutorFactory org.keycloak.services.clientpolicy.executor.RejectRequestExecutorFactory org.keycloak.services.clientpolicy.executor.IntentClientBindCheckExecutorFactory -org.keycloak.services.clientpolicy.executor.SuppressRefreshTokenRotationExecutorFactory \ No newline at end of file +org.keycloak.services.clientpolicy.executor.SuppressRefreshTokenRotationExecutorFactory +org.keycloak.services.clientpolicy.executor.LoAEnforcerExecutorFactory \ No newline at end of file