diff --git a/core/src/main/java/org/keycloak/representations/ClaimsRepresentation.java b/core/src/main/java/org/keycloak/representations/ClaimsRepresentation.java new file mode 100644 index 0000000000..a6f7a6a523 --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/ClaimsRepresentation.java @@ -0,0 +1,154 @@ +/* + * Copyright 2021 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.representations; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Claims parameter as described in the OIDC specification https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter + * + * @author Marek Posolda + */ +public class ClaimsRepresentation { + + @JsonProperty("id_token") + private Map idTokenClaims; + + @JsonProperty("userinfo") + private Map userinfoClaims; + + public Map getIdTokenClaims() { + return idTokenClaims; + } + + public void setIdTokenClaims(Map idTokenClaims) { + this.idTokenClaims = idTokenClaims; + } + + public Map getUserinfoClaims() { + return userinfoClaims; + } + + public void setUserinfoClaims(Map userinfoClaims) { + this.userinfoClaims = userinfoClaims; + } + + // Helper methods + + /** + * + * @param claimName + * @param ctx Whether we ask for claim to be presented in idToken or userInfo + * @return true if claim is presented in the claims parameter either as "null" claim (See OIDC specification for definition of null claim) or claim with some value + */ + public boolean isPresent(String claimName, ClaimContext ctx) { + if (ctx == ClaimContext.ID_TOKEN) { + return idTokenClaims != null && idTokenClaims.containsKey(claimName); + } else if (ctx == ClaimContext.USERINFO){ + return userinfoClaims != null && userinfoClaims.containsKey(claimName); + } else { + throw new IllegalArgumentException("Invalid claim context"); + } + } + + /** + * + * @param claimName + * @param ctx Whether we ask for claim to be presented in idToken or userInfo + * @return true if claim is presented in the claims parameter as "null" claim (See OIDC specification for definition of null claim) + */ + public boolean isPresentAsNullClaim(String claimName, ClaimContext ctx) { + if (!isPresent(claimName, ctx)) return false; + + if (ctx == ClaimContext.ID_TOKEN) { + return idTokenClaims.get(claimName) == null; + } else if (ctx == ClaimContext.USERINFO){ + return userinfoClaims.get(claimName) == null; + } else { + throw new IllegalArgumentException("Invalid claim context"); + } + } + + /** + * + * @param claimName + * @param ctx Whether we ask for claim to be presented in idToken or userInfo + * @param claimType claimType class + * @return Claim value + */ + public ClaimValue getClaimValue(String claimName, ClaimContext ctx, Class claimType) { + if (!isPresent(claimName, ctx)) return null; + + if (ctx == ClaimContext.ID_TOKEN) { + return (ClaimValue) idTokenClaims.get(claimName); + } else if (ctx == ClaimContext.USERINFO){ + return (ClaimValue) userinfoClaims.get(claimName); + } else { + throw new IllegalArgumentException("Invalid claim context"); + } + } + + public enum ClaimContext { + ID_TOKEN, USERINFO + } + + /** + * @param Specifies the type of the claim + */ + public static class ClaimValue { + + private Boolean essential; + + private CLAIM_TYPE value; + + private List values; + + public Boolean getEssential() { + return essential; + } + + public boolean isEssential() { + return essential != null && essential; + } + + public void setEssential(Boolean essential) { + this.essential = essential; + } + + public CLAIM_TYPE getValue() { + return value; + } + + public void setValue(CLAIM_TYPE value) { + this.value = value; + } + + public List getValues() { + return values; + } + + public void setValues(List values) { + this.values = values; + } + } +} diff --git a/core/src/test/java/org/keycloak/JsonParserTest.java b/core/src/test/java/org/keycloak/JsonParserTest.java index b1640fcf60..acdf0c8eca 100755 --- a/core/src/test/java/org/keycloak/JsonParserTest.java +++ b/core/src/test/java/org/keycloak/JsonParserTest.java @@ -19,6 +19,8 @@ package org.keycloak; import org.junit.Assert; import org.junit.Test; +import org.keycloak.common.util.ObjectUtil; +import org.keycloak.representations.ClaimsRepresentation; import org.keycloak.representations.IDToken; import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.adapters.config.AdapterConfig; @@ -217,5 +219,48 @@ public class JsonParserTest { Assert.assertNull(configRep.getConfigAsMap().get("not-existing-option")); } + @Test + public void testReadClaimsParameter() throws Exception { + InputStream is = getClass().getClassLoader().getResourceAsStream("sample-claims.json"); + ClaimsRepresentation claimsRep = JsonSerialization.readValue(is, ClaimsRepresentation.class); + + Assert.assertTrue(claimsRep.isPresent("auth_time", ClaimsRepresentation.ClaimContext.ID_TOKEN)); + Assert.assertFalse(claimsRep.isPresent("auth_time", ClaimsRepresentation.ClaimContext.USERINFO)); + + Assert.assertFalse(claimsRep.isPresentAsNullClaim("auth_time", ClaimsRepresentation.ClaimContext.ID_TOKEN)); + Assert.assertTrue(claimsRep.isPresentAsNullClaim("nickname", ClaimsRepresentation.ClaimContext.USERINFO)); + Assert.assertNull(claimsRep.getClaimValue("nickname", ClaimsRepresentation.ClaimContext.USERINFO, String.class)); + + ClaimsRepresentation.ClaimValue email = claimsRep.getClaimValue("email", ClaimsRepresentation.ClaimContext.USERINFO, String.class); + assertClaimValue(email, true, null); + + ClaimsRepresentation.ClaimValue emailVerified = claimsRep.getClaimValue("email_verified", ClaimsRepresentation.ClaimContext.USERINFO, Boolean.class); + assertClaimValue(emailVerified, true, null); + Assert.assertTrue(emailVerified.isEssential()); + + emailVerified = claimsRep.getClaimValue("email_verified", ClaimsRepresentation.ClaimContext.ID_TOKEN, Boolean.class); + assertClaimValue(emailVerified, false, true); + Assert.assertFalse(emailVerified.isEssential()); + + ClaimsRepresentation.ClaimValue sub = claimsRep.getClaimValue("sub", ClaimsRepresentation.ClaimContext.ID_TOKEN, String.class); + assertClaimValue(sub, null, "248289761001"); + Assert.assertFalse(sub.isEssential()); + + ClaimsRepresentation.ClaimValue acr = claimsRep.getClaimValue("acr", ClaimsRepresentation.ClaimContext.ID_TOKEN, String.class); + assertClaimValue(acr, null, null, "urn:mace:incommon:iap:silver", "urn:mace:incommon:iap:gold"); + } + + private void assertClaimValue(ClaimsRepresentation.ClaimValue claimVal, Boolean expectedEssential, T expectedValue, T... expectedValues) { + Assert.assertTrue(ObjectUtil.isEqualOrBothNull(expectedEssential, claimVal.getEssential())); + Assert.assertTrue(ObjectUtil.isEqualOrBothNull(expectedValue, claimVal.getValue())); + + if (expectedValues == null) { + Assert.assertNull(claimVal.getValues()); + } else { + for (int i = 0; iMarek Posolda + */ +public interface AuthenticationFlowCallback extends Authenticator { + + /** + * Triggered after the authentication flow is successfully finished. The target authentication flow is the one where this + * authenticator is configured. Authenticator should finish successfully in the flow (or being evaluated to true in case of Conditional Authenticator) + * in order to trigger this callback at the successful end of the flow + * + * @param context which encapsulate various useful data + */ + void onParentFlowSuccess(AuthenticationFlowContext context); + +} diff --git a/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationFlowCallbackFactory.java b/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationFlowCallbackFactory.java new file mode 100644 index 0000000000..ada7082cdf --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationFlowCallbackFactory.java @@ -0,0 +1,27 @@ +/* + * Copyright 2021 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; + +/** + * Factory to create {@link AuthenticationFlowCallback} instances. Mostly used as marker interface. + * + * @author Marek Posolda + */ +public interface AuthenticationFlowCallbackFactory extends AuthenticatorFactory { +} diff --git a/server-spi-private/src/main/java/org/keycloak/models/Constants.java b/server-spi-private/src/main/java/org/keycloak/models/Constants.java index 014512a848..348d02dcc6 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/Constants.java +++ b/server-spi-private/src/main/java/org/keycloak/models/Constants.java @@ -130,4 +130,12 @@ public final class Constants { public static final String CLIENT_PROFILES = "client-policies.profiles"; public static final String CLIENT_POLICIES = "client-policies.policies"; + + public static final String LEVEL_OF_AUTHENTICATION = "level-of-authentication"; + public static final String REQUESTED_LEVEL_OF_AUTHENTICATION = "requested-level-of-authentication"; + 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; } diff --git a/services/src/main/java/org/keycloak/authentication/AuthenticationSelectionResolver.java b/services/src/main/java/org/keycloak/authentication/AuthenticationSelectionResolver.java index 221d0bdec9..266eade1eb 100644 --- a/services/src/main/java/org/keycloak/authentication/AuthenticationSelectionResolver.java +++ b/services/src/main/java/org/keycloak/authentication/AuthenticationSelectionResolver.java @@ -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); + return !flow.isConditionalSubflowDisabled(ex, false); }).findFirst().orElse(null); diff --git a/services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java b/services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java new file mode 100755 index 0000000000..e5deb35613 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java @@ -0,0 +1,43 @@ +/* + * Copyright 2021 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.Constants; +import org.keycloak.sessions.AuthenticationSessionModel; + +public class AuthenticatorUtil { + + public static boolean isLevelOfAuthenticationForced(AuthenticationSessionModel authSession) { + return Boolean.parseBoolean(authSession.getClientNote(Constants.FORCE_LEVEL_OF_AUTHENTICATION)); + } + + public static int getRequestedLevelOfAuthentication(AuthenticationSessionModel authSession) { + String requiredLoa = authSession.getClientNote(Constants.REQUESTED_LEVEL_OF_AUTHENTICATION); + return requiredLoa == null ? Constants.NO_LOA : Integer.parseInt(requiredLoa); + } + + public static int getCurrentLevelOfAuthentication(AuthenticationSessionModel authSession) { + String authSessionLoaNote = authSession.getAuthNote(Constants.LEVEL_OF_AUTHENTICATION); + return authSessionLoaNote == null ? Constants.NO_LOA : Integer.parseInt(authSessionLoaNote); + } + + public static boolean isLevelOfAuthenticationSatisfied(AuthenticationSessionModel authSession) { + return AuthenticatorUtil.getRequestedLevelOfAuthentication(authSession) + <= AuthenticatorUtil.getCurrentLevelOfAuthentication(authSession); + } +} diff --git a/services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java b/services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java index d8dbd81b1d..031e51afa3 100755 --- a/services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java +++ b/services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java @@ -26,6 +26,7 @@ import org.keycloak.models.Constants; import org.keycloak.models.UserModel; import org.keycloak.services.ServicesLogger; import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.sessions.CommonClientSessionModel; import javax.ws.rs.HttpMethod; import javax.ws.rs.core.MultivaluedMap; @@ -146,7 +147,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow { checkAndValidateParentFlow(model); return processFlow(); } else { - processor.getAuthenticationSession().setExecutionStatus(model.getId(), AuthenticationSessionModel.ExecutionStatus.CHALLENGED); + setExecutionStatus(model, AuthenticationSessionModel.ExecutionStatus.CHALLENGED); return flowChallenge; } } @@ -169,6 +170,8 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow { /** * Called after "actionExecutionModel" execution is finished (Either successful or attempted). Find the next appropriate authentication * flow where the authentication should continue and continue with authentication process. + * The method recursively continues with the parent flow + * until finally the top flow is processed. * * @param actionExecutionModel * @return Response if some more forms should be displayed during authentication. Null otherwise. @@ -185,8 +188,8 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow { } else { Response response = processSingleFlowExecutionModel(parentFlowExecution, false); if (response == null) { - processor.getAuthenticationSession().removeAuthNote(AuthenticationProcessor.CURRENT_AUTHENTICATION_EXECUTION); - return processFlow(); + // the parent flow is now the last action that has been executed, continue with that until the top flow is reached + return continueAuthenticationAfterSuccessfulAction(parentFlowExecution); } else { return response; } @@ -214,10 +217,10 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow { requiredExecutions, alternativeExecutions); // Note: If we evaluate alternative execution, we will also doublecheck that there are not required elements in same subflow - if ((model.isRequired() && requiredExecutions.stream().allMatch(processor::isSuccessful)) || + if (((model.isRequired() || model.isConditional()) && requiredExecutions.stream().allMatch(processor::isSuccessful)) || (model.isAlternative() && alternativeExecutions.stream().anyMatch(processor::isSuccessful) && requiredExecutions.isEmpty())) { logger.debugf("Flow '%s' successfully finished after children executions success", logExecutionAlias(parentFlowExecutionModel)); - processor.getAuthenticationSession().setExecutionStatus(parentFlowExecutionModel.getId(), AuthenticationSessionModel.ExecutionStatus.SUCCESS); + setExecutionStatus(parentFlowExecutionModel, AuthenticationSessionModel.ExecutionStatus.SUCCESS); // Flow is successfully finished. Recursively check whether it's parent flow is now successful as well model = parentFlowExecutionModel; @@ -246,7 +249,8 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow { while (requiredIListIterator.hasNext()) { AuthenticationExecutionModel required = requiredIListIterator.next(); //Conditional flows must be considered disabled (non-existent) if their condition evaluates to false. - if (required.isConditional() && isConditionalSubflowDisabled(required)) { + //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)) { requiredIListIterator.remove(); continue; } @@ -284,7 +288,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow { } 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 afeList.add(afe); - processor.getAuthenticationSession().setExecutionStatus(alternative.getId(), AuthenticationSessionModel.ExecutionStatus.ATTEMPTED); + setExecutionStatus(alternative, AuthenticationSessionModel.ExecutionStatus.ATTEMPTED); } } } else { @@ -322,9 +326,10 @@ 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 isConditionalSubflowDisabled(AuthenticationExecutionModel model, boolean storeResult) { if (model == null || !model.isAuthenticatorFlow() || !model.isConditional()) { return false; }; @@ -335,7 +340,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow { .filter(s -> s.isEnabled()) .collect(Collectors.toList()); return conditionalAuthenticatorList.isEmpty() || conditionalAuthenticatorList.stream() - .anyMatch(m -> conditionalNotMatched(m, modelList)); + .anyMatch(m -> conditionalNotMatched(m, modelList, storeResult)); } private boolean isConditionalAuthenticator(AuthenticationExecutionModel model) { @@ -350,12 +355,12 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow { return factory; } - private boolean conditionalNotMatched(AuthenticationExecutionModel model, List executionList) { + private boolean conditionalNotMatched(AuthenticationExecutionModel model, List executionList, boolean storeResult) { AuthenticatorFactory factory = getAuthenticatorFactory(model); ConditionalAuthenticator authenticator = (ConditionalAuthenticator) createAuthenticator(factory); AuthenticationProcessor.Result context = processor.createAuthenticatorContext(model, authenticator, executionList); - boolean matchCondition; + boolean matchCondition; // Retrieve previous evaluation result if any, else evaluate and store result for future re-evaluation if (processor.isEvaluatedTrue(model)) { @@ -364,8 +369,10 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow { matchCondition = false; } else { matchCondition = authenticator.matchCondition(context); - processor.getAuthenticationSession().setExecutionStatus(model.getId(), - matchCondition ? AuthenticationSessionModel.ExecutionStatus.EVALUATED_TRUE : AuthenticationSessionModel.ExecutionStatus.EVALUATED_FALSE); + if (storeResult) { + setExecutionStatus(model, + matchCondition ? AuthenticationSessionModel.ExecutionStatus.EVALUATED_TRUE : AuthenticationSessionModel.ExecutionStatus.EVALUATED_FALSE); + } } return !matchCondition; @@ -390,14 +397,14 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow { if (flowChallenge == null) { if (authenticationFlow.isSuccessful()) { logger.debugf("Flow '%s' successfully finished", logExecutionAlias(model)); - processor.getAuthenticationSession().setExecutionStatus(model.getId(), AuthenticationSessionModel.ExecutionStatus.SUCCESS); + setExecutionStatus(model, AuthenticationSessionModel.ExecutionStatus.SUCCESS); } else { logger.debugf("Flow '%s' failed", logExecutionAlias(model)); - processor.getAuthenticationSession().setExecutionStatus(model.getId(), AuthenticationSessionModel.ExecutionStatus.FAILED); + setExecutionStatus(model, AuthenticationSessionModel.ExecutionStatus.FAILED); } return null; } else { - processor.getAuthenticationSession().setExecutionStatus(model.getId(), AuthenticationSessionModel.ExecutionStatus.CHALLENGED); + setExecutionStatus(model, AuthenticationSessionModel.ExecutionStatus.CHALLENGED); return flowChallenge; } } @@ -434,7 +441,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow { if (factory.isUserSetupAllowed() && model.isRequired() && authenticator.areRequiredActionsEnabled(processor.getSession(), processor.getRealm())) { //This means that having even though the user didn't validate the logger.debugv("authenticator SETUP_REQUIRED: {0}", factory.getId()); - processor.getAuthenticationSession().setExecutionStatus(model.getId(), AuthenticationSessionModel.ExecutionStatus.SETUP_REQUIRED); + setExecutionStatus(model, AuthenticationSessionModel.ExecutionStatus.SETUP_REQUIRED); authenticator.setRequiredActions(processor.getSession(), processor.getRealm(), processor.getAuthenticationSession().getAuthenticatedUser()); return null; } else { @@ -485,12 +492,12 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow { switch (status) { case SUCCESS: logger.debugv("authenticator SUCCESS: {0}", execution.getAuthenticator()); - processor.getAuthenticationSession().setExecutionStatus(execution.getId(), AuthenticationSessionModel.ExecutionStatus.SUCCESS); + setExecutionStatus(execution, AuthenticationSessionModel.ExecutionStatus.SUCCESS); return null; case FAILED: logger.debugv("authenticator FAILED: {0}", execution.getAuthenticator()); processor.logFailure(); - processor.getAuthenticationSession().setExecutionStatus(execution.getId(), AuthenticationSessionModel.ExecutionStatus.FAILED); + setExecutionStatus(execution, AuthenticationSessionModel.ExecutionStatus.FAILED); if (result.getChallenge() != null) { return sendChallenge(result, execution); } @@ -501,16 +508,16 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow { throw new ForkFlowException(result.getSuccessMessage(), result.getErrorMessage()); case FORCE_CHALLENGE: case CHALLENGE: - processor.getAuthenticationSession().setExecutionStatus(execution.getId(), AuthenticationSessionModel.ExecutionStatus.CHALLENGED); + setExecutionStatus(execution, AuthenticationSessionModel.ExecutionStatus.CHALLENGED); return sendChallenge(result, execution); case FAILURE_CHALLENGE: logger.debugv("authenticator FAILURE_CHALLENGE: {0}", execution.getAuthenticator()); processor.logFailure(); - processor.getAuthenticationSession().setExecutionStatus(execution.getId(), AuthenticationSessionModel.ExecutionStatus.CHALLENGED); + setExecutionStatus(execution, AuthenticationSessionModel.ExecutionStatus.CHALLENGED); return sendChallenge(result, execution); case ATTEMPTED: logger.debugv("authenticator ATTEMPTED: {0}", execution.getAuthenticator()); - processor.getAuthenticationSession().setExecutionStatus(execution.getId(), AuthenticationSessionModel.ExecutionStatus.ATTEMPTED); + setExecutionStatus(execution, AuthenticationSessionModel.ExecutionStatus.ATTEMPTED); return null; case FLOW_RESET: processor.resetFlow(); @@ -536,4 +543,28 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow { public List getFlowExceptions(){ return afeList; } + + private void setExecutionStatus(AuthenticationExecutionModel authExecutionModel, CommonClientSessionModel.ExecutionStatus status) { + this.processor.getAuthenticationSession().setExecutionStatus(authExecutionModel.getId(), status); + + if (authExecutionModel.isAuthenticatorFlow() && status == CommonClientSessionModel.ExecutionStatus.SUCCESS) { + // Trigger callbacks after flow was successfully finished + processor.getRealm().getAuthenticationExecutionsStream(authExecutionModel.getFlowId()).forEach(this::checkAuthCallback); + } + } + + private void checkAuthCallback(AuthenticationExecutionModel execution) { + // We will trigger the callback just if particular authenticator, which corresponds to this callback, was finished with SUCCESS or condition was evaluated to true + CommonClientSessionModel.ExecutionStatus executionStatus = processor.getAuthenticationSession().getExecutionStatus().get(execution.getId()); + if (executionStatus == CommonClientSessionModel.ExecutionStatus.SUCCESS || executionStatus == CommonClientSessionModel.ExecutionStatus.EVALUATED_TRUE) { + if (!execution.isAuthenticatorFlow()) { + AuthenticatorFactory authFactory = getAuthenticatorFactory(execution); + if (authFactory instanceof AuthenticationFlowCallbackFactory) { + 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 + } + } + } + } } diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/CookieAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/CookieAuthenticator.java index 04597ae4d3..76a8fa61c7 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/CookieAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/CookieAuthenticator.java @@ -19,6 +19,8 @@ package org.keycloak.authentication.authenticators.browser; import org.keycloak.authentication.AuthenticationFlowContext; import org.keycloak.authentication.Authenticator; +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; @@ -44,16 +46,24 @@ public class CookieAuthenticator implements Authenticator { if (authResult == null) { context.attempted(); } else { - AuthenticationSessionModel clientSession = context.getAuthenticationSession(); - LoginProtocol protocol = context.getSession().getProvider(LoginProtocol.class, clientSession.getProtocol()); + AuthenticationSessionModel authSession = context.getAuthenticationSession(); + LoginProtocol protocol = context.getSession().getProvider(LoginProtocol.class, authSession.getProtocol()); + authSession.setAuthNote(Constants.LEVEL_OF_AUTHENTICATION, authResult.getSession().getNote(Constants.LEVEL_OF_AUTHENTICATION)); + context.setUser(authResult.getUser()); // Cookie re-authentication is skipped if re-authentication is required - if (protocol.requireReauthentication(authResult.getSession(), clientSession)) { + if (protocol.requireReauthentication(authResult.getSession(), authSession)) { + // Full re-authentication, so we start with no loa + authSession.setAuthNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(Constants.NO_LOA)); + context.attempted(); + } else if (!AuthenticatorUtil.isLevelOfAuthenticationSatisfied(authSession)) { + // Step-up authentication, we keep the loa from the existing user session. + // The cookie alone is not enough and other authentications must follow. context.attempted(); } else { - context.getAuthenticationSession().setAuthNote(AuthenticationManager.SSO_AUTH, "true"); - - context.setUser(authResult.getUser()); + // Cookie only authentication, no loa is returned + authSession.setAuthNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(Constants.NO_LOA)); + authSession.setAuthNote(AuthenticationManager.SSO_AUTH, "true"); context.attachUserSession(authResult.getSession()); context.success(); } diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalLoaAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalLoaAuthenticator.java new file mode 100644 index 0000000000..16691f838d --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalLoaAuthenticator.java @@ -0,0 +1,92 @@ +/* + * Copyright 2021 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.authenticators.conditional; + +import org.jboss.logging.Logger; +import org.keycloak.authentication.AuthenticationFlowCallback; +import org.keycloak.authentication.AuthenticationFlowContext; +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.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); + + @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) + || ((configuredLoa == null || currentLoa < configuredLoa) && currentLoa < requestedLoa); + } + + @Override + public void onParentFlowSuccess(AuthenticationFlowContext context) { + AuthenticationSessionModel authSession = context.getAuthenticationSession(); + Integer newLoa = getConfiguredLoa(context); + if (newLoa == null) { + return; + } + logger.tracef("Updating LoA to '%d' when authenticating session '%s'", newLoa, authSession.getParentSession().getId()); + authSession.setAuthNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(newLoa)); + if (isStoreInUserSession(context)) { + authSession.setUserSessionNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(newLoa)); + } + } + + private Integer getConfiguredLoa(AuthenticationFlowContext context) { + try { + return Integer.parseInt(context.getAuthenticatorConfig().getConfig().get(LEVEL)); + } catch (NullPointerException | NumberFormatException e) { + logger.errorv("Invalid configuration: {0}", LEVEL); + return null; + } + } + + private boolean isStoreInUserSession(AuthenticationFlowContext context) { + try { + return Boolean.parseBoolean(context.getAuthenticatorConfig().getConfig().get(STORE_IN_USER_SESSION)); + } catch (NullPointerException | NumberFormatException e) { + logger.errorv("Invalid configuration: {0}", STORE_IN_USER_SESSION); + return false; + } + } + + @Override + public void action(AuthenticationFlowContext context) { } + + @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/ConditionalLoaAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalLoaAuthenticatorFactory.java new file mode 100644 index 0000000000..1f1a7ab0f4 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/conditional/ConditionalLoaAuthenticatorFactory.java @@ -0,0 +1,106 @@ +/* + * Copyright 2021 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.authenticators.conditional; + +import java.util.List; +import org.keycloak.Config; +import org.keycloak.authentication.AuthenticationFlowCallbackFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; +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 + }; + + private static final List CONFIG = ProviderConfigurationBuilder.create() + .property() + .name(ConditionalLoaAuthenticator.LEVEL) + .label(ConditionalLoaAuthenticator.LEVEL) + .helpText(ConditionalLoaAuthenticator.LEVEL + ".tooltip") + .type(ProviderConfigProperty.STRING_TYPE) + .add() + .property() + .name(ConditionalLoaAuthenticator.STORE_IN_USER_SESSION) + .label(ConditionalLoaAuthenticator.STORE_IN_USER_SESSION) + .helpText(ConditionalLoaAuthenticator.STORE_IN_USER_SESSION + ".tooltip") + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .defaultValue("true") + .add() + .build(); + + @Override + public void init(Config.Scope config) { } + + @Override + public void postInit(KeycloakSessionFactory factory) { } + + @Override + public void close() { } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getDisplayType() { + return "Condition - Level of Authentication"; + } + + @Override + public String getReferenceCategory() { + return "condition"; + } + + @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 the configured LOA or a higher one has been requested but not yet satisfied. After the flow is successfully finished, the LOA in the session will be updated to value prescribed by this condition."; + } + + @Override + public List getConfigProperties() { + return CONFIG; + } + + @Override + public ConditionalAuthenticator getSingleton() { + return SINGLETON; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java index e1bca46d7f..00442efb84 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -24,6 +24,7 @@ import org.keycloak.OAuth2Constants; import org.keycloak.OAuthErrorException; import org.keycloak.TokenCategory; import org.keycloak.TokenVerifier; +import org.keycloak.authentication.AuthenticatorUtil; import org.keycloak.broker.oidc.OIDCIdentityProvider; import org.keycloak.broker.provider.IdentityBrokerException; import org.keycloak.cluster.ClusterProvider; @@ -43,6 +44,7 @@ import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; import org.keycloak.models.ClientSessionContext; +import org.keycloak.models.Constants; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.RoleModel; @@ -57,6 +59,7 @@ import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper; import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenResponseMapper; import org.keycloak.protocol.oidc.mappers.OIDCIDTokenMapper; import org.keycloak.protocol.oidc.mappers.UserInfoTokenMapper; +import org.keycloak.protocol.oidc.utils.AcrUtils; import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessTokenResponse; @@ -555,6 +558,7 @@ public class TokenManager { userSession.setNote(entry.getKey(), entry.getValue()); } + clientSession.setNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(AuthenticatorUtil.getCurrentLevelOfAuthentication(authSession))); clientSession.setTimestamp(Time.currentTime()); // Remove authentication session now @@ -832,10 +836,7 @@ public class TokenManager { token.setNonce(clientSessionCtx.getAttribute(OIDCLoginProtocol.NONCE_PARAM, String.class)); token.setScope(clientSessionCtx.getScopeString()); - // Best effort for "acr" value. Use 0 if clientSession was authenticated through cookie ( SSO ) - // TODO: Add better acr support. See KEYCLOAK-3314 - String acr = (AuthenticationManager.isSSOAuthentication(clientSession)) ? "0" : "1"; - token.setAcr(acr); + token.setAcr(getAcr(clientSession)); String authTime = session.getNote(AuthenticationManager.AUTH_TIME); if (authTime != null) { @@ -852,6 +853,29 @@ public class TokenManager { return token; } + private String getAcr(AuthenticatedClientSessionModel clientSession) { + int loa = Integer.parseInt(clientSession.getNote(Constants.LEVEL_OF_AUTHENTICATION)); + if (loa < Constants.MINIMUM_LOA) { + loa = AuthenticationManager.isSSOAuthentication(clientSession) ? 0 : 1; + } + + Map acrLoaMap = AcrUtils.getAcrLoaMap(clientSession.getClient()); + String acr = AcrUtils.mapLoaToAcr(loa, acrLoaMap, AcrUtils.getRequiredAcrValues( + clientSession.getNote(OIDCLoginProtocol.CLAIMS_PARAM))); + if (acr == null) { + acr = AcrUtils.mapLoaToAcr(loa, acrLoaMap, AcrUtils.getAcrValues( + clientSession.getNote(OIDCLoginProtocol.CLAIMS_PARAM), + clientSession.getNote(OIDCLoginProtocol.ACR_PARAM))); + if (acr == null) { + acr = AcrUtils.mapLoaToAcr(loa, acrLoaMap, acrLoaMap.keySet()); + if (acr == null) { + acr = String.valueOf(loa); + } + } + } + return acr; + } + private int getTokenExpiration(RealmModel realm, ClientModel client, UserSessionModel userSession, AuthenticatedClientSessionModel clientSession, boolean offlineTokenRequested) { boolean implicitFlow = false; 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 7b46fadb2c..a2f9dd2fac 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,6 +35,7 @@ import org.keycloak.protocol.AuthorizationEndpointBase; 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.OIDCRedirectUriBuilder; import org.keycloak.protocol.oidc.utils.OIDCResponseMode; @@ -57,6 +58,11 @@ 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; + /** * @author Stian Thorgersen */ @@ -286,6 +292,26 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase { if (request.getCodeChallenge() != null) authenticationSession.setClientNote(OIDCLoginProtocol.CODE_CHALLENGE_PARAM, request.getCodeChallenge()); if (request.getCodeChallengeMethod() != null) authenticationSession.setClientNote(OIDCLoginProtocol.CODE_CHALLENGE_METHOD_PARAM, request.getCodeChallengeMethod()); + Map acrLoaMap = AcrUtils.getAcrLoaMap(authenticationSession.getClient()); + List acrValues = AcrUtils.getRequiredAcrValues(request.getClaims()); + + if (acrValues.isEmpty()) { + acrValues = AcrUtils.getAcrValues(request.getClaims(), request.getAcr()); + } else { + authenticationSession.setClientNote(Constants.FORCE_LEVEL_OF_AUTHENTICATION, "true"); + } + + 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, 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; + } + }).min().ifPresent(loa -> authenticationSession.setClientNote(Constants.REQUESTED_LEVEL_OF_AUTHENTICATION, String.valueOf(loa))); + if (request.getAdditionalReqParams() != null) { for (String paramName : request.getAdditionalReqParams().keySet()) { authenticationSession.setClientNote(LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX + paramName, request.getAdditionalReqParams().get(paramName)); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/utils/AcrUtils.java b/services/src/main/java/org/keycloak/protocol/oidc/utils/AcrUtils.java new file mode 100644 index 0000000000..f2927851f7 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/utils/AcrUtils.java @@ -0,0 +1,105 @@ +/* + * Copyright 2021 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.protocol.oidc.utils; + +import com.fasterxml.jackson.core.type.TypeReference; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.jboss.logging.Logger; +import org.keycloak.models.ClientModel; +import org.keycloak.models.Constants; +import org.keycloak.representations.ClaimsRepresentation; +import org.keycloak.representations.IDToken; +import org.keycloak.util.JsonSerialization; + +public class AcrUtils { + + private static final Logger LOGGER = Logger.getLogger(AcrUtils.class); + + public static List getRequiredAcrValues(String claimsParam) { + return getAcrValues(claimsParam, null, false); + } + + public static List getAcrValues(String claimsParam, String acrValuesParam) { + return getAcrValues(claimsParam, acrValuesParam, true); + } + + private static List getAcrValues(String claimsParam, String acrValuesParam, boolean notEssential) { + List acrValues = new ArrayList<>(); + if (acrValuesParam != null && notEssential) { + acrValues.addAll(Arrays.asList(acrValuesParam.split(" "))); + } + if (claimsParam != null) { + try { + ClaimsRepresentation claims = JsonSerialization.readValue(claimsParam, ClaimsRepresentation.class); + ClaimsRepresentation.ClaimValue 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()); + } + } + } + } catch (IOException e) { + LOGGER.warn("Invalid claims parameter", e); + } + } + return acrValues; + } + + public static Map getAcrLoaMap(ClientModel client) { + String map = client.getAttribute(Constants.ACR_LOA_MAP); + if (map == null || map.isEmpty()) { + return Collections.emptyMap(); + } + try { + return JsonSerialization.readValue(map, new TypeReference>() {}); + } catch (IOException e) { + LOGGER.warn("Invalid client configuration (ACR-LOA map)"); + return Collections.emptyMap(); + } + } + + public static String mapLoaToAcr(int loa, Map acrLoaMap, Collection acrValues) { + String acr = null; + if (!acrLoaMap.isEmpty() && !acrValues.isEmpty()) { + int maxLoa = 0; + for (String acrValue : acrValues) { + Integer mappedLoa = acrLoaMap.get(acrValue); + // if there is no mapping for the acrValue, it may be an integer itself + if (mappedLoa == null) { + try { + mappedLoa = Integer.parseInt(acrValue); + } catch (NumberFormatException e) { + // the acrValue cannot be mapped + } + } + if (mappedLoa != null && mappedLoa > maxLoa && loa >= mappedLoa) { + acr = acrValue; + maxLoa = mappedLoa; + } + } + } + return acr; + } +} diff --git a/services/src/main/java/org/keycloak/services/messages/Messages.java b/services/src/main/java/org/keycloak/services/messages/Messages.java index 2c3374dde4..f5f9ca532f 100755 --- a/services/src/main/java/org/keycloak/services/messages/Messages.java +++ b/services/src/main/java/org/keycloak/services/messages/Messages.java @@ -229,6 +229,8 @@ public class Messages { public static final String IDENTITY_PROVIDER_LOGIN_FAILURE = "identityProviderLoginFailure"; + public static final String INSUFFICIENT_LEVEL_OF_AUTHENTICATION = "insufficientLevelOfAuthentication"; + public static final String FAILED_LOGOUT = "failedLogout"; public static final String CONSENT_DENIED="consentDenied"; 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 1bef1797fc..edc1bb4d45 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 @@ -25,6 +25,7 @@ org.keycloak.authentication.authenticators.browser.SpnegoAuthenticatorFactory org.keycloak.authentication.authenticators.browser.IdentityProviderAuthenticatorFactory org.keycloak.authentication.authenticators.conditional.ConditionalRoleAuthenticatorFactory org.keycloak.authentication.authenticators.conditional.ConditionalUserConfiguredAuthenticatorFactory +org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticatorFactory org.keycloak.authentication.authenticators.directgrant.ValidateOTP org.keycloak.authentication.authenticators.directgrant.ValidatePassword org.keycloak.authentication.authenticators.directgrant.ValidateUsername diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/PushTheButtonPage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/PushTheButtonPage.java new file mode 100644 index 0000000000..d31558ca94 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/PushTheButtonPage.java @@ -0,0 +1,50 @@ +/* + * Copyright 2021 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.keycloak.testsuite.util.DroneUtils; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; + +/** + * Page shown during processing of the PushButtonAuthenticator + * + * @author Marek Posolda + */ +public class PushTheButtonPage extends AbstractPage { + + @FindBy(name = "submit1") + private WebElement submitButton; + + @Override + public void open() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isCurrent() { + return DroneUtils.getCurrentDriver().getTitle().equals("PushTheButton") + && !driver.findElements(By.name("submit1")).isEmpty(); + } + + public void submit() { + submitButton.click(); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java index 5021c54cd1..1676737a75 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java @@ -73,6 +73,7 @@ import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentatio import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.representations.AccessToken; import org.keycloak.representations.AuthorizationResponseToken; +import org.keycloak.representations.ClaimsRepresentation; import org.keycloak.representations.IDToken; import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.RefreshToken; @@ -92,6 +93,7 @@ import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; +import java.net.URLEncoder; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.security.KeyStore; @@ -174,6 +176,8 @@ public class OAuthClient { private String requestUri; + private String claims; + private Map requestHeaders; private Map publicKeys = new HashMap<>(); @@ -256,6 +260,7 @@ public class OAuthClient { nonce = null; request = null; requestUri = null; + claims = null; // https://tools.ietf.org/html/rfc7636#section-4 codeVerifier = null; codeChallenge = null; @@ -755,6 +760,9 @@ public class OAuthClient { if (request != null) { parameters.add(new BasicNameValuePair(OIDCLoginProtocol.REQUEST_PARAM, request)); } + if (claims != null) { + parameters.add(new BasicNameValuePair(OIDCLoginProtocol.CLAIMS_PARAM, claims)); + } if (additionalParams != null) { for (Map.Entry entry : additionalParams.entrySet()) { parameters.add(new BasicNameValuePair(entry.getKey(), entry.getValue())); @@ -1121,6 +1129,9 @@ public class OAuthClient { if (requestUri != null) { parameters.add(new BasicNameValuePair(OIDCLoginProtocol.REQUEST_URI_PARAM, requestUri)); } + if (claims != null) { + parameters.add(new BasicNameValuePair(OIDCLoginProtocol.CLAIMS_PARAM, claims)); + } if (codeChallenge != null) { parameters.add(new BasicNameValuePair(OAuth2Constants.CODE_CHALLENGE, codeChallenge)); } @@ -1403,6 +1414,9 @@ public class OAuthClient { if (requestUri != null) { b.queryParam(OIDCLoginProtocol.REQUEST_URI_PARAM, requestUri); } + if (claims != null) { + b.queryParam(OIDCLoginProtocol.CLAIMS_PARAM, claims); + } // https://tools.ietf.org/html/rfc7636#section-4.3 if (codeChallenge != null) { b.queryParam(OAuth2Constants.CODE_CHALLENGE, codeChallenge); @@ -1623,6 +1637,15 @@ public class OAuthClient { return this; } + public OAuthClient claims(ClaimsRepresentation claims) { + try { + this.claims = URLEncoder.encode(JsonSerialization.writeValueAsString(claims), "UTF-8"); + } catch (IOException ioe) { + throw new RuntimeException(ioe); + } + return this; + } + public String getRealm() { return realm; } 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 89356627ec..450de991b2 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 @@ -222,6 +222,9 @@ public class ProvidersTest extends AbstractAuthenticationTest { addProviderInfo(result, "allow-access-authenticator", "Allow access", "Authenticator will always successfully authenticate. Useful for example in the conditional flows to be used after satisfying the previous conditions"); + addProviderInfo(result, "conditional-level-of-authentication", "Condition - Level of Authentication", + "Flow is executed only if the configured LOA or a higher one has been requested but not yet satisfied. After the flow is successfully finished, the LOA in the session will be updated to value prescribed by this condition."); + return result; } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LevelOfAssuranceFlowTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LevelOfAssuranceFlowTest.java new file mode 100644 index 0000000000..4e6b7f020b --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LevelOfAssuranceFlowTest.java @@ -0,0 +1,294 @@ +/* + * Copyright 2021 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 java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import javax.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.authentication.authenticators.browser.PasswordFormFactory; +import org.keycloak.authentication.authenticators.browser.UsernameFormFactory; +import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticator; +import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticatorFactory; +import org.keycloak.events.Details; +import org.keycloak.models.AuthenticationExecutionModel.Requirement; +import org.keycloak.models.Constants; +import org.keycloak.representations.ClaimsRepresentation; +import org.keycloak.representations.IDToken; +import org.keycloak.representations.idm.EventRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; +import org.keycloak.testsuite.AssertEvents; +import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude; +import org.keycloak.testsuite.authentication.PushButtonAuthenticatorFactory; +import org.keycloak.testsuite.pages.ErrorPage; +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.util.JsonSerialization; + +import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.REMOTE; + +/** + * Tests for Level Of Assurance conditions in authentication flow. + * + * @author Sebastian Zoescher + */ +@AuthServerContainerExclude(REMOTE) +public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest { + + @Rule + public AssertEvents events = new AssertEvents(this); + + @Page + protected LoginUsernameOnlyPage loginUsernameOnlyPage; + + @Page + protected PasswordPage passwordPage; + + @Page + protected PushTheButtonPage pushTheButtonPage; + + @Page + protected ErrorPage errorPage; + + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + try { + Map acrLoaMap = new HashMap<>(); + acrLoaMap.put("copper", 0); + acrLoaMap.put("silver", 1); + acrLoaMap.put("gold", 2); + findTestApp(testRealm).setAttributes(Collections.singletonMap(Constants.ACR_LOA_MAP, JsonSerialization.writeValueAsString(acrLoaMap))); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Before + public void setupFlow() { + final String newFlowAlias = "browser - Level of Authentication FLow"; + testingClient.server(TEST_REALM_NAME).run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(newFlowAlias)); + testingClient.server(TEST_REALM_NAME) + .run(session -> FlowUtil.inCurrentRealm(session).selectFlow(newFlowAlias).inForms(forms -> forms.clear() + // level 1 authentication + .addSubFlowExecution(Requirement.CONDITIONAL, subFlow -> { + subFlow.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID, + config -> { + config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "1"); + config.getConfig().put(ConditionalLoaAuthenticator.STORE_IN_USER_SESSION, "true"); + }); + + // username input for level 1 + subFlow.addAuthenticatorExecution(Requirement.REQUIRED, UsernameFormFactory.PROVIDER_ID); + }) + + // level 2 authentication + .addSubFlowExecution(Requirement.CONDITIONAL, subFlow -> { + subFlow.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID, + config -> config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "2")); + + // password required for level 2 + subFlow.addAuthenticatorExecution(Requirement.REQUIRED, PasswordFormFactory.PROVIDER_ID); + }) + + // level 3 authentication + .addSubFlowExecution(Requirement.CONDITIONAL, subFlow -> { + subFlow.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID, + config -> config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "3")); + + // simply push button for level 3 + subFlow.addAuthenticatorExecution(Requirement.REQUIRED, PushButtonAuthenticatorFactory.PROVIDER_ID); + }) + + ).defineAsBrowserFlow()); + } + + @After + public void after() { + BrowserFlowTest.revertFlows(testRealm(), "browser - Level of Authentication FLow"); + } + + @Test + public void loginWithoutAcr() { + oauth.openLoginForm(); + // Authentication without specific LOA results in level 1 authentication + authenticateWithUsername(); + assertLoggedInWithAcr("silver"); + } + + @Test + public void loginWithAcr1() { + // username input for level 1 + openLoginFormWithAcrClaim(true, "silver"); + authenticateWithUsername(); + assertLoggedInWithAcr("silver"); + + } + + @Test + public void loginWithAcr2() { + // username and password input for level 2 + openLoginFormWithAcrClaim(true, "gold"); + authenticateWithUsername(); + authenticateWithPassword(); + assertLoggedInWithAcr("gold"); + } + + @Test + public void loginWithAcr3() { + // username, password input and finally push button for level 3 + openLoginFormWithAcrClaim(true, "3"); + authenticateWithUsername(); + authenticateWithPassword(); + authenticateWithButton(); + // ACR 3 is returned because it was requested, although there is no mapping for it + assertLoggedInWithAcr("3"); + } + + @Test + public void stepupAuthentication() { + // logging in to level 1 + openLoginFormWithAcrClaim(true, "silver"); + authenticateWithUsername(); + assertLoggedInWithAcr("silver"); + // 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"); + authenticateWithPassword(); + authenticateWithButton(); + assertLoggedInWithAcr("3"); + } + + @Test + public void reauthenticationWithNoAcr() { + openLoginFormWithAcrClaim(true, "silver"); + authenticateWithUsername(); + assertLoggedInWithAcr("silver"); + oauth.openLoginForm(); + assertLoggedInWithAcr("0"); + } + + @Test + public void reauthenticationWithReachedAcr() { + openLoginFormWithAcrClaim(true, "silver"); + authenticateWithUsername(); + assertLoggedInWithAcr("silver"); + openLoginFormWithAcrClaim(true, "silver"); + assertLoggedInWithAcr("0"); + } + + @Test + public void reauthenticationWithOptionalUnknownAcr() { + openLoginFormWithAcrClaim(true, "silver"); + authenticateWithUsername(); + assertLoggedInWithAcr("silver"); + openLoginFormWithAcrClaim(false, "iron"); + assertLoggedInWithAcr("0"); + } + + @Test + public void optionalClaimNotReachedSucceeds() { + openLoginFormWithAcrClaim(false, "4"); + authenticateWithUsername(); + authenticateWithPassword(); + authenticateWithButton(); + // the reached loa is 3, but there is no mapping for it, and it was not explicitly + // requested, so the highest known and reached ACR is returned + assertLoggedInWithAcr("gold"); + } + + @Test + public void optionalUnknownClaimSucceeds() { + openLoginFormWithAcrClaim(false, "iron"); + authenticateWithUsername(); + assertLoggedInWithAcr("silver"); + } + + @Test + public void acrValuesQueryParameter() { + driver.navigate().to(UriBuilder.fromUri(oauth.getLoginFormUrl()) + .queryParam("acr_values", "gold 3") + .build().toString()); + authenticateWithUsername(); + authenticateWithPassword(); + assertLoggedInWithAcr("gold"); + } + + @Test + public void multipleEssentialAcrValues() { + openLoginFormWithAcrClaim(true, "gold", "3"); + authenticateWithUsername(); + authenticateWithPassword(); + assertLoggedInWithAcr("gold"); + } + + @Test + public void multipleOptionalAcrValues() { + openLoginFormWithAcrClaim(false, "gold", "3"); + authenticateWithUsername(); + authenticateWithPassword(); + assertLoggedInWithAcr("gold"); + } + + private void openLoginFormWithAcrClaim(boolean essential, String... acrValues) { + ClaimsRepresentation.ClaimValue acrClaim = new ClaimsRepresentation.ClaimValue<>(); + acrClaim.setEssential(essential); + acrClaim.setValues(Arrays.asList(acrValues)); + + ClaimsRepresentation claims = new ClaimsRepresentation(); + claims.setIdTokenClaims(Collections.singletonMap("acr", acrClaim)); + + oauth.claims(claims); + oauth.openLoginForm(); + } + + private void authenticateWithUsername() { + loginUsernameOnlyPage.assertCurrent(); + loginUsernameOnlyPage.login("test-user@localhost"); + } + + private void authenticateWithPassword() { + passwordPage.assertCurrent(); + passwordPage.login("password"); + } + + private void authenticateWithButton() { + pushTheButtonPage.assertCurrent(); + pushTheButtonPage.submit(); + } + + private void assertLoggedInWithAcr(String acr) { + EventRepresentation loginEvent = events.expectLogin().detail(Details.USERNAME, "test-user@localhost").assertEvent(); + IDToken idToken = sendTokenRequestAndGetIDToken(loginEvent); + Assert.assertEquals(acr, idToken.getAcr()); + } +} \ No newline at end of file diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties index 34220b682e..5ab7de7e02 100644 --- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties +++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties @@ -1339,6 +1339,12 @@ otp-token-period=OTP Token Period otp-token-period.tooltip=How many seconds should an OTP token be valid? Defaults to 30 seconds. otp-supported-applications=Supported Applications otp-supported-applications.tooltip=Applications that are known to work with the current OTP policy +loa-level=Level of Authentication +loa-level.tooltip=Sets the Level of Authentication to the specified value. +loa-store-in-user-session=Store LoA in user session +loa-store-in-user-session.tooltip=Additionally stores the LoA in the user session. If true, it means that subsequent authentications in same browser will see this level and they won't repeat authenticating the user with this level. If this is false, it means that subsequent authentications won't see this level and hence they will repeat authentications for this level again. This is useful if some functionality in the application (e.g. send payment) always require authentication with the particular level. +loa-condition-level=Level of Authentication (LoA) +loa-condition-level.tooltip=The condition evaluates to true if user does not yet have this authentication level and this level is requested. This level of authentication will be set to the session after the subflow, where this condition is configured, is successfully finished. table-of-password-policies=Table of Password Policies add-policy.placeholder=Add policy... policy-type=Policy Type @@ -1926,6 +1932,9 @@ pkce-code-challenge-method.tooltip=Choose which code challenge method for PKCE i use-idtoken-as-detached-signature=Use ID Token as a Detached Signature use-idtoken-as-detached-signature.tooltip=This makes ID token returned from Authorization Endpoint in OIDC Hybrid flow use as a detached signature defined in FAPI 1.0 Advanced Security Profile. Therefore, this ID token does not include an authenticated user's information. +acr-loa-map=ACR to LoA Mapping +acr-loa-map.tooltip=Define which ACR (Authentication Context Class Reference) value is mapped to which LoA (Level of Authentication). The ACR can be any value, whereas the LoA must be numeric. + key-not-allowed-here=Key '{{character}}' is not allowed here. # KEYCLOAK-10927 Implement LDAPv3 Password Modify Extended Operation diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js index 9a395d960a..3aba1c39ef 100755 --- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js @@ -1469,6 +1469,12 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro } else { $scope.client.requestUris = []; } + + try { + $scope.acrLoaMap = JSON.parse($scope.client.attributes["acr.loa.map"] || "{}"); + } catch (e) { + $scope.acrLoaMap = {}; + } } if (!$scope.create) { @@ -1606,6 +1612,24 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro $scope.clientEdit.attributes['pkce.code.challenge.method'] = $scope.pkceCodeChallengeMethod; }; + $scope.$watch('newAcr', function() { + $scope.changed = isChanged(); + }, true); + $scope.$watch('newLoa', function() { + $scope.changed = isChanged(); + }, true); + $scope.deleteAcrLoaMapping = function(acr) { + delete $scope.acrLoaMap[acr]; + $scope.changed = true; + } + $scope.addAcrLoaMapping = function() { + if ($scope.newLoa.match(/^[0-9]+$/)) { + $scope.acrLoaMap[$scope.newAcr] = $scope.newLoa; + $scope.newAcr = $scope.newLoa = ""; + $scope.changed = true; + } + } + $scope.changeCibaBackchannelAuthRequestSigningAlg = function() { if ($scope.cibaBackchannelAuthRequestSigningAlg === 'any') { $scope.clientEdit.attributes['ciba.backchannel.auth.request.signing.alg'] = null; @@ -1649,6 +1673,9 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro if ($scope.newRequestUri && $scope.newRequestUri.length > 0) { return true; } + if ($scope.newAcr && $scope.newAcr.length > 0 && $scope.newLoa && $scope.newLoa.length > 0) { + return true; + } return false; } @@ -1811,6 +1838,11 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro } else { $scope.clientEdit.attributes["saml.artifact.binding"] = "false"; } + + if ($scope.newAcr && $scope.newAcr.length > 0 && $scope.newLoa && $scope.newLoa.length > 0) { + $scope.addAcrLoaMapping(); + } + if ($scope.samlServerSignature == true) { $scope.clientEdit.attributes["saml.server.signature"] = "true"; } else { @@ -1941,6 +1973,8 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro $scope.clientEdit.attributes["backchannel.logout.revoke.offline.tokens"] = "false"; } + $scope.clientEdit.attributes["acr.loa.map"] = JSON.stringify($scope.acrLoaMap); + $scope.clientEdit.protocol = $scope.protocol; $scope.clientEdit.attributes['saml.signature.algorithm'] = $scope.signatureAlgorithm; $scope.clientEdit.attributes['saml_name_id_format'] = $scope.nameIdFormat; diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html b/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html index d38b1a8c73..9c81f15528 100755 --- a/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html +++ b/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html @@ -925,6 +925,26 @@ {{:: 'require-pushed-authorization-requests.tooltip' | translate}} +
+ +
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ {{:: 'acr-loa-map.tooltip' | translate}} +
diff --git a/themes/src/main/resources/theme/base/login/messages/messages_en.properties b/themes/src/main/resources/theme/base/login/messages/messages_en.properties index 7419ca00f0..9a6e2f7df8 100755 --- a/themes/src/main/resources/theme/base/login/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/login/messages/messages_en.properties @@ -314,6 +314,7 @@ invalidAccessCodeMessage=Invalid access code. sessionNotActiveMessage=Session not active. invalidCodeMessage=An error occurred, please login again through your application. cookieNotFoundMessage=Cookie not found. Please make sure cookies are enabled in your browser. +insufficientLevelOfAuthentication=The requested level of authentication has not been satisfied. identityProviderUnexpectedErrorMessage=Unexpected error when authenticating with identity provider identityProviderMissingStateMessage=Missing state parameter in response from identity provider. identityProviderNotFoundMessage=Could not find an identity provider with the identifier. diff --git a/themes/src/main/resources/theme/keycloak/admin/resources/css/styles.css b/themes/src/main/resources/theme/keycloak/admin/resources/css/styles.css index 47166f5a5b..bb85710007 100755 --- a/themes/src/main/resources/theme/keycloak/admin/resources/css/styles.css +++ b/themes/src/main/resources/theme/keycloak/admin/resources/css/styles.css @@ -427,6 +427,10 @@ table.kc-authz-table-expanded { font-size: 14px; } +.input-map input.form-control { + width: 50%; +} + /* Deactivation styles for user-group membership tree models */ div[tree-model] li .deactivate { diff --git a/themes/src/main/resources/theme/keycloak/common/resources/node_modules/rcue/dist/img/git-logo.svg b/themes/src/main/resources/theme/keycloak/common/resources/node_modules/rcue/dist/img/git-logo.svg deleted file mode 100644 index 825d8ecc6b..0000000000 --- a/themes/src/main/resources/theme/keycloak/common/resources/node_modules/rcue/dist/img/git-logo.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - -