closes #16884 Signed-off-by: Simon Levermann <github@simon.slevermann.de>
This commit is contained in:
parent
43a59afc00
commit
dcf1d83199
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 |
|
@ -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"]
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Reference in a new issue