Enable enforcement of a minimum ACR at the client level (#16884) (#33205)

closes #16884 

Signed-off-by: Simon Levermann <github@simon.slevermann.de>
This commit is contained in:
Simon Levermann 2024-10-21 13:54:02 +02:00 committed by GitHub
parent 43a59afc00
commit dcf1d83199
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 264 additions and 12 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View file

@ -186,8 +186,11 @@ a `claims` parameter that has an `acr` claim attached. See https://openid.net/sp
WARNING: Note that default ACR values are used as the default level, however it cannot be reliably used to enforce login with the particular level.
For example, assume that you configure the `Default ACR Values` to level 2. Then by default, users will be required to authenticate with level 2.
However when the user explicitly attaches the parameter into login request such as `acr_values=1`, then the level 1 will be used. As a result, if the client
However, when the user explicitly attaches the parameter into login request such as `acr_values=1`, then the level 1 will be used. As a result, if the client
really requires level 2, the client is encouraged to check the presence of the `acr` claim inside ID Token and double-check that it contains the requested level 2.
To actually enforce the usage of a certain ACR on the {project_name} side, use the `Minimum ACR Value` setting.
This allows administrators to enforce ACRs even on applications that are not able to validate the requested `acr` claim inside the token.
image:images/client-oidc-map-acr-to-loa.png[alt="ACR to LoA mapping"]

View file

@ -1360,6 +1360,7 @@ authorizationEncryptedResponseAlgHelp=JWA Algorithm used for key management in e
deleteConfirmGroup_other=Are you sure you want to delete these groups.
scopePermissions.users.manage-description=Policies that decide if an administrator can manage all users in the realm
defaultACRValuesHelp=Default values to be used as voluntary ACR in case that there is no explicit ACR requested by 'claims' or 'acr_values' parameter in the OIDC request.
minimumACRValueHelp=Minimum ACR to be enforced by keycloak. Overrides lower ACRs explicitly requested via 'acr_values' or 'claims', unless marked they are essential
membershipAttributeType=Membership attribute type
eventTypes.PUSHED_AUTHORIZATION_REQUEST.name=Pushed authorization request
included.client.audience.tooltip=The Client ID of the specified audience client will be included in audience (aud) field of the token. If there are existing audiences in the token, the specified value is just added to them. It won't override existing audiences.
@ -1375,6 +1376,7 @@ otpPolicyDigitsHelp=How many digits should the OTP have?
clientAuthentications.client_secret_post=Client secret sent as post
prompts.select_account=Select account
defaultACRValues=Default ACR Values
minimumACRValue=Minimum ACR Value
valueError=A value must be provided.
noConsents=No consents
orderChangeSuccessUserFed=Successfully changed the priority order of user federation providers

View file

@ -1,4 +1,4 @@
import { HelpItem } from "@keycloak/keycloak-ui-shared";
import { HelpItem, TextControl } from "@keycloak/keycloak-ui-shared";
import {
ActionGroup,
Button,
@ -249,6 +249,12 @@ export const AdvancedSettings = ({
stringify
/>
</FormGroup>
<TextControl
type="text"
name={convertAttributeNameToForm("attributes.minimum.acr.value")}
label={t("minimumACRValue")}
labelIcon={t("minimumACRValueHelp")}
/>
</>
)}
<ActionGroup>

View file

@ -163,6 +163,7 @@ public final class Constants {
public static final String FORCE_LEVEL_OF_AUTHENTICATION = "force-level-of-authentication";
public static final String ACR_LOA_MAP = "acr.loa.map";
public static final String DEFAULT_ACR_VALUES = "default.acr.values";
public static final String MINIMUM_ACR_VALUE = "minimum.acr.value";
public static final int MINIMUM_LOA = 0;
public static final int NO_LOA = -1;

View file

@ -21,6 +21,7 @@ import static org.keycloak.protocol.oidc.OIDCConfigAttributes.USE_LOWER_CASE_IN_
import org.keycloak.authentication.authenticators.client.X509ClientAuthenticator;
import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.services.util.DPoPUtil;
import org.keycloak.utils.StringUtil;
@ -106,7 +107,7 @@ public class OIDCAdvancedConfigWrapper extends AbstractClientConfigWrapper {
public String getRequestObjectRequired() {
return getAttribute(OIDCConfigAttributes.REQUEST_OBJECT_REQUIRED);
}
public void setRequestObjectRequired(String requestObjectRequired) {
setAttribute(OIDCConfigAttributes.REQUEST_OBJECT_REQUIRED, requestObjectRequired);
}
@ -413,4 +414,11 @@ public class OIDCAdvancedConfigWrapper extends AbstractClientConfigWrapper {
setAttributeMultivalued(OIDCConfigAttributes.POST_LOGOUT_REDIRECT_URIS, postLogoutRedirectUris);
}
public String getMinimumAcrValue() {
return getAttribute(Constants.MINIMUM_ACR_VALUE);
}
public void setMinimumAcrValue(String minimumAcrValue) {
setAttribute(Constants.MINIMUM_ACR_VALUE, minimumAcrValue);
}
}

View file

@ -62,6 +62,7 @@ import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.BiConsumer;
@ -317,6 +318,14 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
if (acrValues.isEmpty()) {
acrValues = AcrUtils.getAcrValues(request.getClaims(), request.getAcr(), authenticationSession.getClient());
} else {
List<String> minimizedAcrValues = AcrUtils.enforceMinimumAcr(acrValues, client);
// If enforcing a minimum here changes the list, the client has an essential claim that is too low
if (!minimizedAcrValues.equals(acrValues)) {
logger.errorf("Requested essential acr value list contains values lower than the client minimum. Please doublecheck the client configuration or correct ACR passed in the 'claims' parameter.");
event.detail(Details.REASON, "Invalid requested essential acr value");
event.error(Errors.INVALID_REQUEST);
throw new ErrorPageException(session, authenticationSession, Response.Status.BAD_REQUEST, Messages.INVALID_PARAMETER, OIDCLoginProtocol.CLAIMS_PARAM);
}
authenticationSession.setClientNote(Constants.FORCE_LEVEL_OF_AUTHENTICATION, "true");
}

View file

@ -23,9 +23,13 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.jboss.logging.Logger;
import org.keycloak.authentication.authenticators.util.LoAUtil;
import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
import org.keycloak.models.RealmModel;
@ -42,14 +46,61 @@ public class AcrUtils {
return getAcrValues(claimsParam, null, true);
}
public static List<String> getAcrValues(String claimsParam, String acrValuesParam, ClientModel client) {
List<String> fromParams = getAcrValues(claimsParam, acrValuesParam, false);
if (!fromParams.isEmpty()) {
return fromParams;
}
// Fallback to default ACR values of client (if configured)
return getDefaultAcrValues(client);
public static List<String> getAcrValues(String claimsParam, String acrValuesParam, ClientModel client) {
List<String> acrValues = getAcrValues(claimsParam, acrValuesParam, false);
if (acrValues.isEmpty()) {
// Fallback to default ACR values of client (if configured)
acrValues = getDefaultAcrValues(client);
}
return enforceMinimumAcr(acrValues, client);
}
public static List<String> enforceMinimumAcr(List<String> acrValues, ClientModel client) {
String minimumAcr = getMinimumAcrValue(client);
// If a minimum is set, we need to validate the client didn't request a lower ACR
if (minimumAcr != null) {
List<String> acrCopy = new ArrayList<>(acrValues);
Map<String, Integer> acrMap = getAcrLoaMap(client);
Integer minimumLoa = getLoaForAcr(minimumAcr, acrMap, client);
if (minimumLoa == null) {
LOGGER.warnf("ACR '%s' can not be mapped to a LoA value.", minimumAcr);
} else {
// Remove all ACRs lower than the minimum
Iterator<String> iterator = acrCopy.iterator();
while (iterator.hasNext()) {
String acrValue = iterator.next();
Integer loa = getLoaForAcr(acrValue, acrMap, client);
if (loa == null) {
LOGGER.warnf("ACR '%s' can not be mapped to a LoA value.", acrValue);
iterator.remove();
} else if (loa < minimumLoa) {
iterator.remove();
}
}
// All ACRs lower than the minimum are gone, if we have none left, add our minimum
if (acrCopy.isEmpty()) {
acrCopy.add(minimumAcr);
}
}
return acrCopy;
}
return acrValues;
}
private static Integer getLoaForAcr(String acr, Map<String, Integer> acrMap, ClientModel client) {
Integer loa = acrMap.get(acr);
if (loa == null) {
Optional<Integer> loaFromFlows = LoAUtil.getLoAConfiguredInRealmBrowserFlow(client.getRealm())
.filter(l -> acr.equals(String.valueOf(l)))
.findFirst();
if (loaFromFlows.isPresent()) {
loa = loaFromFlows.get();
}
}
return loa;
}
private static List<String> getAcrValues(String claimsParam, String acrValuesParam, boolean essential) {
@ -152,4 +203,8 @@ public class AcrUtils {
public static List<String> getDefaultAcrValues(ClientModel client) {
return OIDCAdvancedConfigWrapper.fromClientModel(client).getAttributeMultivalued(Constants.DEFAULT_ACR_VALUES);
}
public static String getMinimumAcrValue(ClientModel client) {
return OIDCAdvancedConfigWrapper.fromClientModel(client).getMinimumAcrValue();
}
}

View file

@ -31,6 +31,7 @@ import org.keycloak.protocol.saml.SamlProtocol;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.oidc.OIDCClientRepresentation;
import org.keycloak.services.util.ResolveRelative;
import org.keycloak.services.validation.Validation;
import java.net.MalformedURLException;
import java.net.URI;
@ -185,6 +186,7 @@ public class DefaultClientValidationProvider implements ClientValidationProvider
new CibaClientValidation(context).validate();
validateJwks(context);
validateDefaultAcrValues(context);
validateMinimumAcrValue(context);
return context.toResult();
}
@ -195,6 +197,7 @@ public class DefaultClientValidationProvider implements ClientValidationProvider
validatePairwiseInOIDCClient(context);
new CibaClientValidation(context).validate();
validateDefaultAcrValues(context);
validateMinimumAcrValue(context);
return context.toResult();
}
@ -379,10 +382,28 @@ public class DefaultClientValidationProvider implements ClientValidationProvider
}
for (String configuredAcr : defaultAcrValues) {
if (acrToLoaMap.containsKey(configuredAcr)) continue;
if (!LoAUtil.getLoAConfiguredInRealmBrowserFlow(client.getRealm())
.anyMatch(level -> configuredAcr.equals(String.valueOf(level)))) {
if (LoAUtil.getLoAConfiguredInRealmBrowserFlow(client.getRealm())
.noneMatch(level -> configuredAcr.equals(String.valueOf(level)))) {
context.addError("defaultAcrValues", "Default ACR values need to contain values specified in the ACR-To-Loa mapping or number levels from set realm browser flow");
}
}
}
private void validateMinimumAcrValue(ValidationContext<ClientModel> context) {
ClientModel client = context.getObjectToValidate();
String minimumAcrValue = AcrUtils.getMinimumAcrValue(client);
if (minimumAcrValue != null) {
Map<String, Integer> acrToLoaMap = AcrUtils.getAcrLoaMap(client);
if (acrToLoaMap.isEmpty()) {
acrToLoaMap = AcrUtils.getAcrLoaMap(client.getRealm());
}
if(!acrToLoaMap.containsKey(minimumAcrValue)) {
if (LoAUtil.getLoAConfiguredInRealmBrowserFlow(client.getRealm())
.noneMatch(level -> minimumAcrValue.equals(String.valueOf(level)))) {
context.addError("minimumAcrValue", "Minimum ACR value needs to be value specified in the ACR-To-Loa mapping or number level from set realm browser flow");
}
}
}
}
}

View file

@ -524,6 +524,41 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
testRealm().update(realmRep);
}
@Test
public void testClientMinimumAcrValueValidation() throws IOException {
// Setup realm acr-to-loa mapping
RealmRepresentation realmRep = testRealm().toRepresentation();
Map<String, Integer> acrLoaMap = new HashMap<>();
acrLoaMap.put("realm:copper", 0);
acrLoaMap.put("realm:silver", 1);
realmRep.getAttributes().put(Constants.ACR_LOA_MAP, JsonSerialization.writeValueAsString(acrLoaMap));
testRealm().update(realmRep);
// Value "foo" not used in any ACR-To-Loa mapping
ClientResource testClient = ApiUtil.findClientByClientId(testRealm(), "test-app");
ClientRepresentation testClientRep = testClient.toRepresentation();
OIDCAdvancedConfigWrapper.fromClientRepresentation(testClientRep).setMinimumAcrValue("foo");
Assert.assertThrows(BadRequestException.class, () -> {
testClient.update(testClientRep);
});
// Realm value should not be considered either
OIDCAdvancedConfigWrapper.fromClientRepresentation(testClientRep).setMinimumAcrValue("realm:silver");
Assert.assertThrows(BadRequestException.class, () -> {
testClient.update(testClientRep);
});
// Value from client map should be OK
OIDCAdvancedConfigWrapper.fromClientRepresentation(testClientRep).setMinimumAcrValue("silver");
testClient.update(testClientRep);
// Cleanup
OIDCAdvancedConfigWrapper.fromClientRepresentation(testClientRep).setMinimumAcrValue(null);
testClient.update(testClientRep);
realmRep.getAttributes().remove(Constants.ACR_LOA_MAP);
testRealm().update(realmRep);
}
// After initial authentication with "acr=2", there will be further re-authentication requests sent in different intervals
// without "acr" parameter. User should be always re-authenticated due SSO, but with different acr levels due their gradual expirations
@Test
@ -893,6 +928,118 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
}
}
@Test
public void testLoginWithMinimumAcrWithoutAcrValues() {
ClientResource testClient = ApiUtil.findClientByClientId(testRealm(), "test-app");
ClientRepresentation testClientRep = testClient.toRepresentation();
OIDCAdvancedConfigWrapper.fromClientRepresentation(testClientRep).setMinimumAcrValue("gold");
testClient.update(testClientRep);
// Should request client to authenticate with gold
oauth.openLoginForm();
authenticateWithUsernamePassword();
authenticateWithTotp();
assertLoggedInWithAcr("gold");
// Revert
OIDCAdvancedConfigWrapper.fromClientRepresentation(testClientRep).setMinimumAcrValue(null);
testClient.update(testClientRep);
}
@Test
public void testLoginWithMinimumAcrWithLowerAcrValues() {
ClientResource testClient = ApiUtil.findClientByClientId(testRealm(), "test-app");
ClientRepresentation testClientRep = testClient.toRepresentation();
OIDCAdvancedConfigWrapper.fromClientRepresentation(testClientRep).setMinimumAcrValue("gold");
testClient.update(testClientRep);
// Should request client to authenticate with gold, even if the client sends silver
driver.navigate().to(UriBuilder.fromUri(oauth.getLoginFormUrl())
.queryParam("acr_values", "silver")
.build().toString());
authenticateWithUsernamePassword();
authenticateWithTotp();
assertLoggedInWithAcr("gold");
// Revert
OIDCAdvancedConfigWrapper.fromClientRepresentation(testClientRep).setMinimumAcrValue(null);
testClient.update(testClientRep);
}
@Test
public void testLoginWithMinimumAcrWithHigherAcrValues() {
ClientResource testClient = ApiUtil.findClientByClientId(testRealm(), "test-app");
ClientRepresentation testClientRep = testClient.toRepresentation();
OIDCAdvancedConfigWrapper.fromClientRepresentation(testClientRep).setMinimumAcrValue("gold");
testClient.update(testClientRep);
// Should request client to authenticate with gold, even if the client sends silver
driver.navigate().to(UriBuilder.fromUri(oauth.getLoginFormUrl())
.queryParam("acr_values", "3")
.build().toString());
authenticateWithUsernamePassword();
authenticateWithTotp();
authenticateWithButton();
assertLoggedInWithAcr("3");
// Revert
OIDCAdvancedConfigWrapper.fromClientRepresentation(testClientRep).setMinimumAcrValue(null);
testClient.update(testClientRep);
}
@Test
public void testEssentialAcrMinimumOk() {
ClientResource testClient = ApiUtil.findClientByClientId(testRealm(), "test-app");
ClientRepresentation testClientRep = testClient.toRepresentation();
OIDCAdvancedConfigWrapper.fromClientRepresentation(testClientRep).setMinimumAcrValue("gold");
testClient.update(testClientRep);
// username, password input and finally push button for gold
openLoginFormWithAcrClaim(true, "gold");
authenticateWithUsernamePassword();
authenticateWithTotp();
assertLoggedInWithAcr("gold");
// Revert
OIDCAdvancedConfigWrapper.fromClientRepresentation(testClientRep).setMinimumAcrValue(null);
testClient.update(testClientRep);
}
@Test
public void testEssentialAcrMinimumTooLow() {
ClientResource testClient = ApiUtil.findClientByClientId(testRealm(), "test-app");
ClientRepresentation testClientRep = testClient.toRepresentation();
OIDCAdvancedConfigWrapper.fromClientRepresentation(testClientRep).setMinimumAcrValue("gold");
testClient.update(testClientRep);
// requesting a too low essential acr should fail
openLoginFormWithAcrClaim(true, "silver");
assertErrorPage("Invalid parameter: claims");
// Revert
OIDCAdvancedConfigWrapper.fromClientRepresentation(testClientRep).setMinimumAcrValue(null);
testClient.update(testClientRep);
}
@Test
public void testNonEssentialAcrMinimumUpgrade() {
ClientResource testClient = ApiUtil.findClientByClientId(testRealm(), "test-app");
ClientRepresentation testClientRep = testClient.toRepresentation();
OIDCAdvancedConfigWrapper.fromClientRepresentation(testClientRep).setMinimumAcrValue("gold");
testClient.update(testClientRep);
// requesting a too low non-essential ACR should be upgraded
openLoginFormWithAcrClaim(false, "silver");
authenticateWithUsernamePassword();
authenticateWithTotp();
assertLoggedInWithAcr("gold");
// Revert
OIDCAdvancedConfigWrapper.fromClientRepresentation(testClientRep).setMinimumAcrValue(null);
testClient.update(testClientRep);
}
private String getCredentialIdByLabel(String credentialLabel) {
return ApiUtil.findUserByUsernameId(testRealm(), "test-user@localhost").credentials()
.stream()