KEYCLOAK-847 Add support for step up authentication (#7897)

KEYCLOAK-847 Fix behavior of unknown not essential acr claim

Co-authored-by: Georg Romstorfer <georg.romstorfer@gmail.com>
Co-authored-by: Marek Posolda <mposolda@gmail.com>
This commit is contained in:
CorneliaLahnsteiner 2021-12-22 12:43:12 +01:00 committed by GitHub
parent dfcf46ca60
commit dff79cee3c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1203 additions and 54 deletions

View file

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class ClaimsRepresentation {
@JsonProperty("id_token")
private Map<String, ClaimValue> idTokenClaims;
@JsonProperty("userinfo")
private Map<String, ClaimValue> userinfoClaims;
public Map<String, ClaimValue> getIdTokenClaims() {
return idTokenClaims;
}
public void setIdTokenClaims(Map<String, ClaimValue> idTokenClaims) {
this.idTokenClaims = idTokenClaims;
}
public Map<String, ClaimValue> getUserinfoClaims() {
return userinfoClaims;
}
public void setUserinfoClaims(Map<String, ClaimValue> 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 <CLAIM_TYPE> ClaimValue<CLAIM_TYPE> getClaimValue(String claimName, ClaimContext ctx, Class<CLAIM_TYPE> claimType) {
if (!isPresent(claimName, ctx)) return null;
if (ctx == ClaimContext.ID_TOKEN) {
return (ClaimValue<CLAIM_TYPE>) idTokenClaims.get(claimName);
} else if (ctx == ClaimContext.USERINFO){
return (ClaimValue<CLAIM_TYPE>) userinfoClaims.get(claimName);
} else {
throw new IllegalArgumentException("Invalid claim context");
}
}
public enum ClaimContext {
ID_TOKEN, USERINFO
}
/**
* @param <CLAIM_TYPE> Specifies the type of the claim
*/
public static class ClaimValue<CLAIM_TYPE> {
private Boolean essential;
private CLAIM_TYPE value;
private List<CLAIM_TYPE> 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<CLAIM_TYPE> getValues() {
return values;
}
public void setValues(List<CLAIM_TYPE> values) {
this.values = values;
}
}
}

View file

@ -19,6 +19,8 @@ package org.keycloak;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Test; import org.junit.Test;
import org.keycloak.common.util.ObjectUtil;
import org.keycloak.representations.ClaimsRepresentation;
import org.keycloak.representations.IDToken; import org.keycloak.representations.IDToken;
import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.adapters.config.AdapterConfig; import org.keycloak.representations.adapters.config.AdapterConfig;
@ -217,5 +219,48 @@ public class JsonParserTest {
Assert.assertNull(configRep.getConfigAsMap().get("not-existing-option")); 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<String> email = claimsRep.getClaimValue("email", ClaimsRepresentation.ClaimContext.USERINFO, String.class);
assertClaimValue(email, true, null);
ClaimsRepresentation.ClaimValue<Boolean> 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<String> sub = claimsRep.getClaimValue("sub", ClaimsRepresentation.ClaimContext.ID_TOKEN, String.class);
assertClaimValue(sub, null, "248289761001");
Assert.assertFalse(sub.isEssential());
ClaimsRepresentation.ClaimValue<String> 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 <T> void assertClaimValue(ClaimsRepresentation.ClaimValue<T> 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; i<expectedValues.length ; i++) {
Assert.assertEquals(expectedValues[i], claimVal.getValues().get(i));
}
}
}
} }

View file

@ -0,0 +1,18 @@
{
"userinfo":
{
"given_name": {"essential": true},
"nickname": null,
"email": {"essential": true},
"email_verified": {"essential": true},
"picture": null,
"http://example.info/claims/groups": null
},
"id_token":
{
"auth_time": {"essential": true},
"sub": {"value": "248289761001"},
"email_verified": {"essential": false, "value": true },
"acr": {"values": ["urn:mace:incommon:iap:silver", "urn:mace:incommon:iap:gold"] }
}
}

View file

@ -23,6 +23,7 @@ import static org.keycloak.quarkus.runtime.configuration.ConfigArgsConfigSource.
import java.io.File; import java.io.File;
import java.lang.reflect.Field; import java.lang.reflect.Field;
import java.nio.file.Paths;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Properties; import java.util.Properties;

View file

@ -0,0 +1,39 @@
/*
* 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;
/**
* Callback to be triggered during various lifecycle events of authentication flow.
*
* The {@link AuthenticatorFactory}, which creates this Authenticator should implement {@link AuthenticationFlowCallbackFactory} interface.
*
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
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);
}

View file

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public interface AuthenticationFlowCallbackFactory extends AuthenticatorFactory {
}

View file

@ -130,4 +130,12 @@ public final class Constants {
public static final String CLIENT_PROFILES = "client-policies.profiles"; public static final String CLIENT_PROFILES = "client-policies.profiles";
public static final String CLIENT_POLICIES = "client-policies.policies"; 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;
} }

View file

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

View file

@ -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);
}
}

View file

@ -26,6 +26,7 @@ import org.keycloak.models.Constants;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.services.ServicesLogger; import org.keycloak.services.ServicesLogger;
import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.sessions.CommonClientSessionModel;
import javax.ws.rs.HttpMethod; import javax.ws.rs.HttpMethod;
import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.MultivaluedMap;
@ -146,7 +147,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
checkAndValidateParentFlow(model); checkAndValidateParentFlow(model);
return processFlow(); return processFlow();
} else { } else {
processor.getAuthenticationSession().setExecutionStatus(model.getId(), AuthenticationSessionModel.ExecutionStatus.CHALLENGED); setExecutionStatus(model, AuthenticationSessionModel.ExecutionStatus.CHALLENGED);
return flowChallenge; 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 * 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. * 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 * @param actionExecutionModel
* @return Response if some more forms should be displayed during authentication. Null otherwise. * @return Response if some more forms should be displayed during authentication. Null otherwise.
@ -185,8 +188,8 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
} else { } else {
Response response = processSingleFlowExecutionModel(parentFlowExecution, false); Response response = processSingleFlowExecutionModel(parentFlowExecution, false);
if (response == null) { if (response == null) {
processor.getAuthenticationSession().removeAuthNote(AuthenticationProcessor.CURRENT_AUTHENTICATION_EXECUTION); // the parent flow is now the last action that has been executed, continue with that until the top flow is reached
return processFlow(); return continueAuthenticationAfterSuccessfulAction(parentFlowExecution);
} else { } else {
return response; return response;
} }
@ -214,10 +217,10 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
requiredExecutions, alternativeExecutions); requiredExecutions, alternativeExecutions);
// Note: If we evaluate alternative execution, we will also doublecheck that there are not required elements in same subflow // 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())) { (model.isAlternative() && alternativeExecutions.stream().anyMatch(processor::isSuccessful) && requiredExecutions.isEmpty())) {
logger.debugf("Flow '%s' successfully finished after children executions success", logExecutionAlias(parentFlowExecutionModel)); 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 // Flow is successfully finished. Recursively check whether it's parent flow is now successful as well
model = parentFlowExecutionModel; model = parentFlowExecutionModel;
@ -246,7 +249,8 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
while (requiredIListIterator.hasNext()) { while (requiredIListIterator.hasNext()) {
AuthenticationExecutionModel required = requiredIListIterator.next(); AuthenticationExecutionModel required = requiredIListIterator.next();
//Conditional flows must be considered disabled (non-existent) if their condition evaluates to false. //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(); requiredIListIterator.remove();
continue; continue;
} }
@ -284,7 +288,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
} catch (AuthenticationFlowException afe) { } 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 //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); afeList.add(afe);
processor.getAuthenticationSession().setExecutionStatus(alternative.getId(), AuthenticationSessionModel.ExecutionStatus.ATTEMPTED); setExecutionStatus(alternative, AuthenticationSessionModel.ExecutionStatus.ATTEMPTED);
} }
} }
} else { } else {
@ -322,9 +326,10 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
/** /**
* Checks if the conditional subflow passed in parameter is disabled. * Checks if the conditional subflow passed in parameter is disabled.
* @param model * @param model
* @param storeResult whether to store the result of the conditional evaluations
* @return * @return
*/ */
boolean isConditionalSubflowDisabled(AuthenticationExecutionModel model) { boolean isConditionalSubflowDisabled(AuthenticationExecutionModel model, boolean storeResult) {
if (model == null || !model.isAuthenticatorFlow() || !model.isConditional()) { if (model == null || !model.isAuthenticatorFlow() || !model.isConditional()) {
return false; return false;
}; };
@ -335,7 +340,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
.filter(s -> s.isEnabled()) .filter(s -> s.isEnabled())
.collect(Collectors.toList()); .collect(Collectors.toList());
return conditionalAuthenticatorList.isEmpty() || conditionalAuthenticatorList.stream() return conditionalAuthenticatorList.isEmpty() || conditionalAuthenticatorList.stream()
.anyMatch(m -> conditionalNotMatched(m, modelList)); .anyMatch(m -> conditionalNotMatched(m, modelList, storeResult));
} }
private boolean isConditionalAuthenticator(AuthenticationExecutionModel model) { private boolean isConditionalAuthenticator(AuthenticationExecutionModel model) {
@ -350,12 +355,12 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
return factory; return factory;
} }
private boolean conditionalNotMatched(AuthenticationExecutionModel model, List<AuthenticationExecutionModel> executionList) { private boolean conditionalNotMatched(AuthenticationExecutionModel model, List<AuthenticationExecutionModel> executionList, boolean storeResult) {
AuthenticatorFactory factory = getAuthenticatorFactory(model); AuthenticatorFactory factory = getAuthenticatorFactory(model);
ConditionalAuthenticator authenticator = (ConditionalAuthenticator) createAuthenticator(factory); ConditionalAuthenticator authenticator = (ConditionalAuthenticator) createAuthenticator(factory);
AuthenticationProcessor.Result context = processor.createAuthenticatorContext(model, authenticator, executionList); 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 // Retrieve previous evaluation result if any, else evaluate and store result for future re-evaluation
if (processor.isEvaluatedTrue(model)) { if (processor.isEvaluatedTrue(model)) {
@ -364,8 +369,10 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
matchCondition = false; matchCondition = false;
} else { } else {
matchCondition = authenticator.matchCondition(context); matchCondition = authenticator.matchCondition(context);
processor.getAuthenticationSession().setExecutionStatus(model.getId(), if (storeResult) {
matchCondition ? AuthenticationSessionModel.ExecutionStatus.EVALUATED_TRUE : AuthenticationSessionModel.ExecutionStatus.EVALUATED_FALSE); setExecutionStatus(model,
matchCondition ? AuthenticationSessionModel.ExecutionStatus.EVALUATED_TRUE : AuthenticationSessionModel.ExecutionStatus.EVALUATED_FALSE);
}
} }
return !matchCondition; return !matchCondition;
@ -390,14 +397,14 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
if (flowChallenge == null) { if (flowChallenge == null) {
if (authenticationFlow.isSuccessful()) { if (authenticationFlow.isSuccessful()) {
logger.debugf("Flow '%s' successfully finished", logExecutionAlias(model)); logger.debugf("Flow '%s' successfully finished", logExecutionAlias(model));
processor.getAuthenticationSession().setExecutionStatus(model.getId(), AuthenticationSessionModel.ExecutionStatus.SUCCESS); setExecutionStatus(model, AuthenticationSessionModel.ExecutionStatus.SUCCESS);
} else { } else {
logger.debugf("Flow '%s' failed", logExecutionAlias(model)); logger.debugf("Flow '%s' failed", logExecutionAlias(model));
processor.getAuthenticationSession().setExecutionStatus(model.getId(), AuthenticationSessionModel.ExecutionStatus.FAILED); setExecutionStatus(model, AuthenticationSessionModel.ExecutionStatus.FAILED);
} }
return null; return null;
} else { } else {
processor.getAuthenticationSession().setExecutionStatus(model.getId(), AuthenticationSessionModel.ExecutionStatus.CHALLENGED); setExecutionStatus(model, AuthenticationSessionModel.ExecutionStatus.CHALLENGED);
return flowChallenge; return flowChallenge;
} }
} }
@ -434,7 +441,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
if (factory.isUserSetupAllowed() && model.isRequired() && authenticator.areRequiredActionsEnabled(processor.getSession(), processor.getRealm())) { if (factory.isUserSetupAllowed() && model.isRequired() && authenticator.areRequiredActionsEnabled(processor.getSession(), processor.getRealm())) {
//This means that having even though the user didn't validate the //This means that having even though the user didn't validate the
logger.debugv("authenticator SETUP_REQUIRED: {0}", factory.getId()); 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()); authenticator.setRequiredActions(processor.getSession(), processor.getRealm(), processor.getAuthenticationSession().getAuthenticatedUser());
return null; return null;
} else { } else {
@ -485,12 +492,12 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
switch (status) { switch (status) {
case SUCCESS: case SUCCESS:
logger.debugv("authenticator SUCCESS: {0}", execution.getAuthenticator()); logger.debugv("authenticator SUCCESS: {0}", execution.getAuthenticator());
processor.getAuthenticationSession().setExecutionStatus(execution.getId(), AuthenticationSessionModel.ExecutionStatus.SUCCESS); setExecutionStatus(execution, AuthenticationSessionModel.ExecutionStatus.SUCCESS);
return null; return null;
case FAILED: case FAILED:
logger.debugv("authenticator FAILED: {0}", execution.getAuthenticator()); logger.debugv("authenticator FAILED: {0}", execution.getAuthenticator());
processor.logFailure(); processor.logFailure();
processor.getAuthenticationSession().setExecutionStatus(execution.getId(), AuthenticationSessionModel.ExecutionStatus.FAILED); setExecutionStatus(execution, AuthenticationSessionModel.ExecutionStatus.FAILED);
if (result.getChallenge() != null) { if (result.getChallenge() != null) {
return sendChallenge(result, execution); return sendChallenge(result, execution);
} }
@ -501,16 +508,16 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
throw new ForkFlowException(result.getSuccessMessage(), result.getErrorMessage()); throw new ForkFlowException(result.getSuccessMessage(), result.getErrorMessage());
case FORCE_CHALLENGE: case FORCE_CHALLENGE:
case CHALLENGE: case CHALLENGE:
processor.getAuthenticationSession().setExecutionStatus(execution.getId(), AuthenticationSessionModel.ExecutionStatus.CHALLENGED); setExecutionStatus(execution, AuthenticationSessionModel.ExecutionStatus.CHALLENGED);
return sendChallenge(result, execution); return sendChallenge(result, execution);
case FAILURE_CHALLENGE: case FAILURE_CHALLENGE:
logger.debugv("authenticator FAILURE_CHALLENGE: {0}", execution.getAuthenticator()); logger.debugv("authenticator FAILURE_CHALLENGE: {0}", execution.getAuthenticator());
processor.logFailure(); processor.logFailure();
processor.getAuthenticationSession().setExecutionStatus(execution.getId(), AuthenticationSessionModel.ExecutionStatus.CHALLENGED); setExecutionStatus(execution, AuthenticationSessionModel.ExecutionStatus.CHALLENGED);
return sendChallenge(result, execution); return sendChallenge(result, execution);
case ATTEMPTED: case ATTEMPTED:
logger.debugv("authenticator ATTEMPTED: {0}", execution.getAuthenticator()); logger.debugv("authenticator ATTEMPTED: {0}", execution.getAuthenticator());
processor.getAuthenticationSession().setExecutionStatus(execution.getId(), AuthenticationSessionModel.ExecutionStatus.ATTEMPTED); setExecutionStatus(execution, AuthenticationSessionModel.ExecutionStatus.ATTEMPTED);
return null; return null;
case FLOW_RESET: case FLOW_RESET:
processor.resetFlow(); processor.resetFlow();
@ -536,4 +543,28 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
public List<AuthenticationFlowException> getFlowExceptions(){ public List<AuthenticationFlowException> getFlowExceptions(){
return afeList; 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
}
}
}
}
} }

View file

@ -19,6 +19,8 @@ package org.keycloak.authentication.authenticators.browser;
import org.keycloak.authentication.AuthenticationFlowContext; import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.Authenticator; import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorUtil;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
@ -44,16 +46,24 @@ public class CookieAuthenticator implements Authenticator {
if (authResult == null) { if (authResult == null) {
context.attempted(); context.attempted();
} else { } else {
AuthenticationSessionModel clientSession = context.getAuthenticationSession(); AuthenticationSessionModel authSession = context.getAuthenticationSession();
LoginProtocol protocol = context.getSession().getProvider(LoginProtocol.class, clientSession.getProtocol()); 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 // 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(); context.attempted();
} else { } else {
context.getAuthenticationSession().setAuthNote(AuthenticationManager.SSO_AUTH, "true"); // Cookie only authentication, no loa is returned
authSession.setAuthNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(Constants.NO_LOA));
context.setUser(authResult.getUser()); authSession.setAuthNote(AuthenticationManager.SSO_AUTH, "true");
context.attachUserSession(authResult.getSession()); context.attachUserSession(authResult.getSession());
context.success(); context.success();
} }

View file

@ -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() { }
}

View file

@ -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<ProviderConfigProperty> 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<ProviderConfigProperty> getConfigProperties() {
return CONFIG;
}
@Override
public ConditionalAuthenticator getSingleton() {
return SINGLETON;
}
}

View file

@ -24,6 +24,7 @@ import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException; import org.keycloak.OAuthErrorException;
import org.keycloak.TokenCategory; import org.keycloak.TokenCategory;
import org.keycloak.TokenVerifier; import org.keycloak.TokenVerifier;
import org.keycloak.authentication.AuthenticatorUtil;
import org.keycloak.broker.oidc.OIDCIdentityProvider; import org.keycloak.broker.oidc.OIDCIdentityProvider;
import org.keycloak.broker.provider.IdentityBrokerException; import org.keycloak.broker.provider.IdentityBrokerException;
import org.keycloak.cluster.ClusterProvider; import org.keycloak.cluster.ClusterProvider;
@ -43,6 +44,7 @@ import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel; import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel; import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.ClientSessionContext; import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel; 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.OIDCAccessTokenResponseMapper;
import org.keycloak.protocol.oidc.mappers.OIDCIDTokenMapper; import org.keycloak.protocol.oidc.mappers.OIDCIDTokenMapper;
import org.keycloak.protocol.oidc.mappers.UserInfoTokenMapper; import org.keycloak.protocol.oidc.mappers.UserInfoTokenMapper;
import org.keycloak.protocol.oidc.utils.AcrUtils;
import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.protocol.oidc.utils.OIDCResponseType;
import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.AccessTokenResponse;
@ -555,6 +558,7 @@ public class TokenManager {
userSession.setNote(entry.getKey(), entry.getValue()); userSession.setNote(entry.getKey(), entry.getValue());
} }
clientSession.setNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(AuthenticatorUtil.getCurrentLevelOfAuthentication(authSession)));
clientSession.setTimestamp(Time.currentTime()); clientSession.setTimestamp(Time.currentTime());
// Remove authentication session now // Remove authentication session now
@ -832,10 +836,7 @@ public class TokenManager {
token.setNonce(clientSessionCtx.getAttribute(OIDCLoginProtocol.NONCE_PARAM, String.class)); token.setNonce(clientSessionCtx.getAttribute(OIDCLoginProtocol.NONCE_PARAM, String.class));
token.setScope(clientSessionCtx.getScopeString()); token.setScope(clientSessionCtx.getScopeString());
// Best effort for "acr" value. Use 0 if clientSession was authenticated through cookie ( SSO ) token.setAcr(getAcr(clientSession));
// TODO: Add better acr support. See KEYCLOAK-3314
String acr = (AuthenticationManager.isSSOAuthentication(clientSession)) ? "0" : "1";
token.setAcr(acr);
String authTime = session.getNote(AuthenticationManager.AUTH_TIME); String authTime = session.getNote(AuthenticationManager.AUTH_TIME);
if (authTime != null) { if (authTime != null) {
@ -852,6 +853,29 @@ public class TokenManager {
return token; 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<String, Integer> 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, private int getTokenExpiration(RealmModel realm, ClientModel client, UserSessionModel userSession,
AuthenticatedClientSessionModel clientSession, boolean offlineTokenRequested) { AuthenticatedClientSessionModel clientSession, boolean offlineTokenRequested) {
boolean implicitFlow = false; boolean implicitFlow = false;

View file

@ -35,6 +35,7 @@ import org.keycloak.protocol.AuthorizationEndpointBase;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest; import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest;
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequestParserProcessor; 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.grants.device.endpoints.DeviceEndpoint;
import org.keycloak.protocol.oidc.utils.OIDCRedirectUriBuilder; import org.keycloak.protocol.oidc.utils.OIDCRedirectUriBuilder;
import org.keycloak.protocol.oidc.utils.OIDCResponseMode; 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.MultivaluedMap;
import javax.ws.rs.core.Response; 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 <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/ */
@ -286,6 +292,26 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
if (request.getCodeChallenge() != null) authenticationSession.setClientNote(OIDCLoginProtocol.CODE_CHALLENGE_PARAM, request.getCodeChallenge()); 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()); if (request.getCodeChallengeMethod() != null) authenticationSession.setClientNote(OIDCLoginProtocol.CODE_CHALLENGE_METHOD_PARAM, request.getCodeChallengeMethod());
Map<String, Integer> acrLoaMap = AcrUtils.getAcrLoaMap(authenticationSession.getClient());
List<String> 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) { if (request.getAdditionalReqParams() != null) {
for (String paramName : request.getAdditionalReqParams().keySet()) { for (String paramName : request.getAdditionalReqParams().keySet()) {
authenticationSession.setClientNote(LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX + paramName, request.getAdditionalReqParams().get(paramName)); authenticationSession.setClientNote(LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX + paramName, request.getAdditionalReqParams().get(paramName));

View file

@ -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<String> getRequiredAcrValues(String claimsParam) {
return getAcrValues(claimsParam, null, false);
}
public static List<String> getAcrValues(String claimsParam, String acrValuesParam) {
return getAcrValues(claimsParam, acrValuesParam, true);
}
private static List<String> getAcrValues(String claimsParam, String acrValuesParam, boolean notEssential) {
List<String> 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<String> acrClaim = claims.getClaimValue(IDToken.ACR, ClaimsRepresentation.ClaimContext.ID_TOKEN, String.class);
if (acrClaim != null) {
if (notEssential || acrClaim.isEssential()) {
if (acrClaim.getValues() != null) {
acrValues.addAll(acrClaim.getValues());
}
}
}
} catch (IOException e) {
LOGGER.warn("Invalid claims parameter", e);
}
}
return acrValues;
}
public static Map<String, Integer> 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<Map<String, Integer>>() {});
} catch (IOException e) {
LOGGER.warn("Invalid client configuration (ACR-LOA map)");
return Collections.emptyMap();
}
}
public static String mapLoaToAcr(int loa, Map<String, Integer> acrLoaMap, Collection<String> 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;
}
}

View file

@ -229,6 +229,8 @@ public class Messages {
public static final String IDENTITY_PROVIDER_LOGIN_FAILURE = "identityProviderLoginFailure"; 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 FAILED_LOGOUT = "failedLogout";
public static final String CONSENT_DENIED="consentDenied"; public static final String CONSENT_DENIED="consentDenied";

View file

@ -25,6 +25,7 @@ org.keycloak.authentication.authenticators.browser.SpnegoAuthenticatorFactory
org.keycloak.authentication.authenticators.browser.IdentityProviderAuthenticatorFactory org.keycloak.authentication.authenticators.browser.IdentityProviderAuthenticatorFactory
org.keycloak.authentication.authenticators.conditional.ConditionalRoleAuthenticatorFactory org.keycloak.authentication.authenticators.conditional.ConditionalRoleAuthenticatorFactory
org.keycloak.authentication.authenticators.conditional.ConditionalUserConfiguredAuthenticatorFactory org.keycloak.authentication.authenticators.conditional.ConditionalUserConfiguredAuthenticatorFactory
org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticatorFactory
org.keycloak.authentication.authenticators.directgrant.ValidateOTP org.keycloak.authentication.authenticators.directgrant.ValidateOTP
org.keycloak.authentication.authenticators.directgrant.ValidatePassword org.keycloak.authentication.authenticators.directgrant.ValidatePassword
org.keycloak.authentication.authenticators.directgrant.ValidateUsername org.keycloak.authentication.authenticators.directgrant.ValidateUsername

View file

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
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();
}
}

View file

@ -73,6 +73,7 @@ import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentatio
import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.protocol.oidc.utils.OIDCResponseType;
import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AuthorizationResponseToken; import org.keycloak.representations.AuthorizationResponseToken;
import org.keycloak.representations.ClaimsRepresentation;
import org.keycloak.representations.IDToken; import org.keycloak.representations.IDToken;
import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.RefreshToken; import org.keycloak.representations.RefreshToken;
@ -92,6 +93,7 @@ import java.io.IOException;
import java.io.UnsupportedEncodingException; import java.io.UnsupportedEncodingException;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.nio.charset.Charset; import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.KeyStore; import java.security.KeyStore;
@ -174,6 +176,8 @@ public class OAuthClient {
private String requestUri; private String requestUri;
private String claims;
private Map<String, String> requestHeaders; private Map<String, String> requestHeaders;
private Map<String, JSONWebKeySet> publicKeys = new HashMap<>(); private Map<String, JSONWebKeySet> publicKeys = new HashMap<>();
@ -256,6 +260,7 @@ public class OAuthClient {
nonce = null; nonce = null;
request = null; request = null;
requestUri = null; requestUri = null;
claims = null;
// https://tools.ietf.org/html/rfc7636#section-4 // https://tools.ietf.org/html/rfc7636#section-4
codeVerifier = null; codeVerifier = null;
codeChallenge = null; codeChallenge = null;
@ -755,6 +760,9 @@ public class OAuthClient {
if (request != null) { if (request != null) {
parameters.add(new BasicNameValuePair(OIDCLoginProtocol.REQUEST_PARAM, request)); parameters.add(new BasicNameValuePair(OIDCLoginProtocol.REQUEST_PARAM, request));
} }
if (claims != null) {
parameters.add(new BasicNameValuePair(OIDCLoginProtocol.CLAIMS_PARAM, claims));
}
if (additionalParams != null) { if (additionalParams != null) {
for (Map.Entry<String, String> entry : additionalParams.entrySet()) { for (Map.Entry<String, String> entry : additionalParams.entrySet()) {
parameters.add(new BasicNameValuePair(entry.getKey(), entry.getValue())); parameters.add(new BasicNameValuePair(entry.getKey(), entry.getValue()));
@ -1121,6 +1129,9 @@ public class OAuthClient {
if (requestUri != null) { if (requestUri != null) {
parameters.add(new BasicNameValuePair(OIDCLoginProtocol.REQUEST_URI_PARAM, requestUri)); parameters.add(new BasicNameValuePair(OIDCLoginProtocol.REQUEST_URI_PARAM, requestUri));
} }
if (claims != null) {
parameters.add(new BasicNameValuePair(OIDCLoginProtocol.CLAIMS_PARAM, claims));
}
if (codeChallenge != null) { if (codeChallenge != null) {
parameters.add(new BasicNameValuePair(OAuth2Constants.CODE_CHALLENGE, codeChallenge)); parameters.add(new BasicNameValuePair(OAuth2Constants.CODE_CHALLENGE, codeChallenge));
} }
@ -1403,6 +1414,9 @@ public class OAuthClient {
if (requestUri != null) { if (requestUri != null) {
b.queryParam(OIDCLoginProtocol.REQUEST_URI_PARAM, requestUri); 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 // https://tools.ietf.org/html/rfc7636#section-4.3
if (codeChallenge != null) { if (codeChallenge != null) {
b.queryParam(OAuth2Constants.CODE_CHALLENGE, codeChallenge); b.queryParam(OAuth2Constants.CODE_CHALLENGE, codeChallenge);
@ -1623,6 +1637,15 @@ public class OAuthClient {
return this; 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() { public String getRealm() {
return realm; return realm;
} }

View file

@ -222,6 +222,9 @@ public class ProvidersTest extends AbstractAuthenticationTest {
addProviderInfo(result, "allow-access-authenticator", "Allow access", 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"); "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; return result;
} }

View file

@ -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 <a href="mailto:sebastian.zoescher@prime-sign.com">Sebastian Zoescher</a>
*/
@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<String, Integer> 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<String> 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());
}
}

View file

@ -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-token-period.tooltip=How many seconds should an OTP token be valid? Defaults to 30 seconds.
otp-supported-applications=Supported Applications otp-supported-applications=Supported Applications
otp-supported-applications.tooltip=Applications that are known to work with the current OTP policy 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 table-of-password-policies=Table of Password Policies
add-policy.placeholder=Add policy... add-policy.placeholder=Add policy...
policy-type=Policy Type 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=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. 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. key-not-allowed-here=Key '{{character}}' is not allowed here.
# KEYCLOAK-10927 Implement LDAPv3 Password Modify Extended Operation # KEYCLOAK-10927 Implement LDAPv3 Password Modify Extended Operation

View file

@ -1469,6 +1469,12 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
} else { } else {
$scope.client.requestUris = []; $scope.client.requestUris = [];
} }
try {
$scope.acrLoaMap = JSON.parse($scope.client.attributes["acr.loa.map"] || "{}");
} catch (e) {
$scope.acrLoaMap = {};
}
} }
if (!$scope.create) { 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.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() { $scope.changeCibaBackchannelAuthRequestSigningAlg = function() {
if ($scope.cibaBackchannelAuthRequestSigningAlg === 'any') { if ($scope.cibaBackchannelAuthRequestSigningAlg === 'any') {
$scope.clientEdit.attributes['ciba.backchannel.auth.request.signing.alg'] = null; $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) { if ($scope.newRequestUri && $scope.newRequestUri.length > 0) {
return true; return true;
} }
if ($scope.newAcr && $scope.newAcr.length > 0 && $scope.newLoa && $scope.newLoa.length > 0) {
return true;
}
return false; return false;
} }
@ -1811,6 +1838,11 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
} else { } else {
$scope.clientEdit.attributes["saml.artifact.binding"] = "false"; $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) { if ($scope.samlServerSignature == true) {
$scope.clientEdit.attributes["saml.server.signature"] = "true"; $scope.clientEdit.attributes["saml.server.signature"] = "true";
} else { } 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["backchannel.logout.revoke.offline.tokens"] = "false";
} }
$scope.clientEdit.attributes["acr.loa.map"] = JSON.stringify($scope.acrLoaMap);
$scope.clientEdit.protocol = $scope.protocol; $scope.clientEdit.protocol = $scope.protocol;
$scope.clientEdit.attributes['saml.signature.algorithm'] = $scope.signatureAlgorithm; $scope.clientEdit.attributes['saml.signature.algorithm'] = $scope.signatureAlgorithm;
$scope.clientEdit.attributes['saml_name_id_format'] = $scope.nameIdFormat; $scope.clientEdit.attributes['saml_name_id_format'] = $scope.nameIdFormat;

View file

@ -925,6 +925,26 @@
<kc-tooltip>{{:: 'require-pushed-authorization-requests.tooltip' | translate}}</kc-tooltip> <kc-tooltip>{{:: 'require-pushed-authorization-requests.tooltip' | translate}}</kc-tooltip>
</div> </div>
<div class="form-group clearfix block" data-ng-show="(!clientEdit.bearerOnly && protocol == 'openid-connect') && (clientEdit.standardFlowEnabled || clientEdit.directAccessGrantsEnabled || clientEdit.implicitFlowEnabled)">
<label class="col-md-2 control-label" for="newAcr">{{:: 'acr-loa-map' | translate}}</label>
<div class="col-sm-6">
<div class="input-group input-map" ng-repeat="(acr, loa) in acrLoaMap">
<input class="form-control" readonly value="{{acr}}">
<input class="form-control" ng-model="acrLoaMap[acr]">
<div class="input-group-btn">
<button class="btn btn-default" type="button" data-ng-click="deleteAcrLoaMapping(acr)"><span class="fa fa-minus"></span></button>
</div>
</div>
<div class="input-group input-map">
<input class="form-control" ng-model="newAcr" id="newAcr" placeholder="ACR">
<input class="form-control" ng-model="newLoa" id="newLoa" placeholder="LOA">
<div class="input-group-btn">
<button class="btn btn-default" type="button" data-ng-click="newAcr.length > 0 && newLoa.length > 0 && addAcrLoaMapping()"><span class="fa fa-plus"></span></button>
</div>
</div>
</div>
<kc-tooltip>{{:: 'acr-loa-map.tooltip' | translate}}</kc-tooltip>
</div>
</fieldset> </fieldset>
<fieldset> <fieldset>

View file

@ -314,6 +314,7 @@ invalidAccessCodeMessage=Invalid access code.
sessionNotActiveMessage=Session not active. sessionNotActiveMessage=Session not active.
invalidCodeMessage=An error occurred, please login again through your application. invalidCodeMessage=An error occurred, please login again through your application.
cookieNotFoundMessage=Cookie not found. Please make sure cookies are enabled in your browser. 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 identityProviderUnexpectedErrorMessage=Unexpected error when authenticating with identity provider
identityProviderMissingStateMessage=Missing state parameter in response from identity provider. identityProviderMissingStateMessage=Missing state parameter in response from identity provider.
identityProviderNotFoundMessage=Could not find an identity provider with the identifier. identityProviderNotFoundMessage=Could not find an identity provider with the identifier.

View file

@ -427,6 +427,10 @@ table.kc-authz-table-expanded {
font-size: 14px; font-size: 14px;
} }
.input-map input.form-control {
width: 50%;
}
/* Deactivation styles for user-group membership tree models */ /* Deactivation styles for user-group membership tree models */
div[tree-model] li .deactivate { div[tree-model] li .deactivate {

View file

@ -1,21 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="20px" height="20px" viewBox="0 0 20 20" style="enable-background:new 0 0 20 20;" xml:space="preserve">
<style type="text/css">
.st0{clip-path:url(#SVGID_2_);fill:#F05133;}
</style>
<g>
<defs>
<rect id="SVGID_1_" x="0" y="0" width="20" height="20"/>
</defs>
<clipPath id="SVGID_2_">
<use xlink:href="#SVGID_1_" style="overflow:visible;"/>
</clipPath>
<path class="st0" d="M19.6,9.1l-8.7-8.7c-0.5-0.5-1.3-0.5-1.8,0L7.3,2.2l2.3,2.3c0.5-0.2,1.1-0.1,1.6,0.4c0.4,0.4,0.5,1,0.4,1.6
l2.2,2.2c0.5-0.2,1.2-0.1,1.6,0.4c0.6,0.6,0.6,1.6,0,2.2c-0.6,0.6-1.6,0.6-2.2,0c-0.5-0.5-0.6-1.1-0.3-1.7l-2.1-2.1v5.4
c0.1,0.1,0.3,0.2,0.4,0.3c0.6,0.6,0.6,1.6,0,2.2C10.5,16,9.6,16,9,15.4c-0.6-0.6-0.6-1.6,0-2.2c0.1-0.1,0.3-0.3,0.5-0.3V7.4
C9.3,7.3,9.1,7.2,9,7C8.5,6.6,8.4,5.9,8.6,5.3L6.4,3.1l-6,6c-0.5,0.5-0.5,1.3,0,1.8l8.7,8.7c0.5,0.5,1.3,0.5,1.8,0l8.7-8.7
C20.1,10.4,20.1,9.6,19.6,9.1"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB