From caf37b1f709957a41f5b2ffe0040873946dacc85 Mon Sep 17 00:00:00 2001 From: Marek Posolda Date: Fri, 18 Feb 2022 11:33:31 +0100 Subject: [PATCH] Support for acr_values_supported in OIDC well-known endpoint (#10265) * Support for acr_values_supported in OIDC well-known endpoint closes #10159 --- .../OIDCConfigurationRepresentation.java | 11 ++ .../authentication/AuthenticatorUtil.java | 62 ++++++++++ .../protocol/oidc/OIDCWellKnownProvider.java | 17 +++ .../protocol/oidc/utils/AcrUtils.java | 35 +++++- .../forms/LevelOfAssuranceFlowTest.java | 114 +++++++++++++----- .../oidc/OIDCWellKnownProviderTest.java | 56 +++++++++ .../messages/admin-messages_en.properties | 3 +- .../admin/resources/js/controllers/realm.js | 56 ++++++++- .../resources/partials/client-detail.html | 2 +- .../partials/realm-login-settings.html | 20 +++ 10 files changed, 340 insertions(+), 36 deletions(-) diff --git a/core/src/main/java/org/keycloak/protocol/oidc/representations/OIDCConfigurationRepresentation.java b/core/src/main/java/org/keycloak/protocol/oidc/representations/OIDCConfigurationRepresentation.java index f8e0e8f420..87b3f7fae0 100755 --- a/core/src/main/java/org/keycloak/protocol/oidc/representations/OIDCConfigurationRepresentation.java +++ b/core/src/main/java/org/keycloak/protocol/oidc/representations/OIDCConfigurationRepresentation.java @@ -64,6 +64,9 @@ public class OIDCConfigurationRepresentation { @JsonProperty("grant_types_supported") private List grantTypesSupported; + @JsonProperty("acr_values_supported") + private List acrValuesSupported; + @JsonProperty("response_types_supported") private List responseTypesSupported; @@ -258,6 +261,14 @@ public class OIDCConfigurationRepresentation { this.grantTypesSupported = grantTypesSupported; } + public List getAcrValuesSupported() { + return acrValuesSupported; + } + + public void setAcrValuesSupported(List acrValuesSupported) { + this.acrValuesSupported = acrValuesSupported; + } + public List getResponseTypesSupported() { return responseTypesSupported; } diff --git a/services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java b/services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java index f6b574ff0b..595d533a7a 100755 --- a/services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java +++ b/services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java @@ -18,18 +18,30 @@ package org.keycloak.authentication; import com.google.common.collect.Sets; +import org.jboss.logging.Logger; +import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticator; +import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticatorFactory; import org.keycloak.models.AuthenticatedClientSessionModel; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.AuthenticatorConfigModel; import org.keycloak.models.Constants; +import org.keycloak.models.RealmModel; import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.utils.StringUtil; import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; import java.util.Set; +import java.util.stream.Stream; import static org.keycloak.services.managers.AuthenticationManager.SSO_AUTH; public class AuthenticatorUtil { + private static final Logger logger = Logger.getLogger(AuthenticatorUtil.class); + // It is used for identification of note included in authentication session for storing callback provider factories public static String CALLBACKS_FACTORY_IDS_NOTE = "callbacksFactoryProviderIds"; @@ -103,4 +115,54 @@ public class AuthenticatorUtil { return Collections.emptySet(); } } + + + /** + * @param realm + * @param flowId + * @param providerId + * @return all executions of given "provider_id" type. This is deep (recursive) obtain of executions of the particular flow + */ + public static List getExecutionsByType(RealmModel realm, String flowId, String providerId) { + List executions = new LinkedList<>(); + realm.getAuthenticationExecutionsStream(flowId).forEach(authExecution -> { + if (providerId.equals(authExecution.getAuthenticator())) { + executions.add(authExecution); + } else if (authExecution.isAuthenticatorFlow() && authExecution.getFlowId() != null) { + executions.addAll(getExecutionsByType(realm, authExecution.getFlowId(), providerId)); + } + }); + return executions; + } + + /** + * @param realm + * @return All LoA numbers configured in the conditions in the realm browser flow + */ + public static Stream getLoAConfiguredInRealmBrowserFlow(RealmModel realm) { + List loaConditions = getExecutionsByType(realm, realm.getBrowserFlow().getId(), ConditionalLoaAuthenticatorFactory.PROVIDER_ID); + if (loaConditions.isEmpty()) { + // Default values used when step-up conditions not used in the browser authentication flow. + // This is used for backwards compatibility and in case when step-up is not configured in the authentication flow (returning 1 in case of "normal" authentication, 0 for SSO authentication) + return Stream.of(Constants.MINIMUM_LOA, 1); + } else { + Stream configuredLoas = loaConditions.stream() + .map(authExecution -> realm.getAuthenticatorConfigById(authExecution.getAuthenticatorConfig())) + .filter(Objects::nonNull) + .map(authConfig -> { + String levelAsStr = authConfig.getConfig().get(ConditionalLoaAuthenticator.LEVEL); + try { + // Check it can be cast to number + return Integer.parseInt(levelAsStr); + } catch (NullPointerException | NumberFormatException e) { + logger.warnf("Invalid level '%s' configured for the configuration of LoA condition with alias '%s'. Level should be number.", levelAsStr, authConfig.getAlias()); + return null; + } + }) + .filter(Objects::nonNull); + + // Add 0 as a level used for SSO cookie + return Stream.concat(Stream.of(Constants.MINIMUM_LOA), configuredLoas); + } + } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java index 2a24a746c8..73ee936ead 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java @@ -19,6 +19,7 @@ package org.keycloak.protocol.oidc; import com.google.common.collect.Streams; import org.keycloak.OAuth2Constants; +import org.keycloak.authentication.AuthenticatorUtil; import org.keycloak.authentication.ClientAuthenticator; import org.keycloak.authentication.ClientAuthenticatorFactory; import org.keycloak.crypto.CekManagementProvider; @@ -28,6 +29,7 @@ import org.keycloak.crypto.SignatureProvider; import org.keycloak.jose.jws.Algorithm; import org.keycloak.models.CibaConfig; import org.keycloak.models.ClientScopeModel; +import org.keycloak.models.Constants; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint; @@ -37,6 +39,7 @@ import org.keycloak.protocol.oidc.grants.device.endpoints.DeviceEndpoint; import org.keycloak.protocol.oidc.par.endpoints.ParEndpoint; import org.keycloak.protocol.oidc.representations.MTLSEndpointAliases; import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; +import org.keycloak.protocol.oidc.utils.AcrUtils; import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.provider.Provider; import org.keycloak.provider.ProviderFactory; @@ -54,6 +57,7 @@ import javax.ws.rs.core.UriInfo; import java.net.URI; import java.util.AbstractMap; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.List; @@ -145,6 +149,7 @@ public class OIDCWellKnownProvider implements WellKnownProvider { config.setSubjectTypesSupported(DEFAULT_SUBJECT_TYPES_SUPPORTED); config.setResponseModesSupported(DEFAULT_RESPONSE_MODES_SUPPORTED); config.setGrantTypesSupported(DEFAULT_GRANT_TYPES_SUPPORTED); + config.setAcrValuesSupported(getAcrValuesSupported(realm)); config.setTokenEndpointAuthMethodsSupported(getClientAuthMethodsSupported()); config.setTokenEndpointAuthSigningAlgValuesSupported(getSupportedClientSigningAlgorithms(false)); @@ -253,6 +258,18 @@ public class OIDCWellKnownProvider implements WellKnownProvider { return getSupportedAlgorithms(ContentEncryptionProvider.class, false); } + private List getAcrValuesSupported(RealmModel realm) { + // Values explicitly set on the realm mapping + Map realmAcrLoaMap = AcrUtils.getAcrLoaMap(realm); + List result = new ArrayList<>(realmAcrLoaMap.keySet()); + + // Add LoA levels configured in authentication flow in addition to the realm values + result.addAll(AuthenticatorUtil.getLoAConfiguredInRealmBrowserFlow(realm) + .map(String::valueOf) + .collect(Collectors.toList())); + return result; + } + private List getSupportedEncryptionAlgorithms() { return getSupportedAlgorithms(CekManagementProvider.class, false); } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/utils/AcrUtils.java b/services/src/main/java/org/keycloak/protocol/oidc/utils/AcrUtils.java index 8bcd5880fa..85fc66c5a3 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/utils/AcrUtils.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/utils/AcrUtils.java @@ -28,6 +28,7 @@ import java.util.Map; import org.jboss.logging.Logger; import org.keycloak.models.ClientModel; import org.keycloak.models.Constants; +import org.keycloak.models.RealmModel; import org.keycloak.representations.ClaimsRepresentation; import org.keycloak.representations.IDToken; import org.keycloak.util.JsonSerialization; @@ -71,7 +72,22 @@ public class AcrUtils { return acrValues; } + /** + * @param client + * @return map corresponding to "acr-to-loa" client attribute. It will fallback to realm in case "acr-to-loa" mapping not configured on client + */ public static Map getAcrLoaMap(ClientModel client) { + Map result = getAcrLoaMapForClientOnly(client); + if (result.isEmpty()) { + // Fallback to realm + return getAcrLoaMap(client.getRealm()); + } else { + return result; + } + } + + + private static Map getAcrLoaMapForClientOnly(ClientModel client) { String map = client.getAttribute(Constants.ACR_LOA_MAP); if (map == null || map.isEmpty()) { return Collections.emptyMap(); @@ -79,7 +95,24 @@ public class AcrUtils { try { return JsonSerialization.readValue(map, new TypeReference>() {}); } catch (IOException e) { - LOGGER.warn("Invalid client configuration (ACR-LOA map)"); + LOGGER.warnf("Invalid client configuration (ACR-LOA map) for client '%s'", client.getClientId()); + return Collections.emptyMap(); + } + } + + /** + * @param realm + * @return map corresponding to "acr-to-loa" realm attribute. + */ + public static Map getAcrLoaMap(RealmModel realm) { + String map = realm.getAttribute(Constants.ACR_LOA_MAP); + if (map == null || map.isEmpty()) { + return Collections.emptyMap(); + } + try { + return JsonSerialization.readValue(map, new TypeReference>() {}); + } catch (IOException e) { + LOGGER.warn("Invalid realm configuration (ACR-LOA map)"); return Collections.emptyMap(); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LevelOfAssuranceFlowTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LevelOfAssuranceFlowTest.java index 85f764338c..73a21f0318 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LevelOfAssuranceFlowTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LevelOfAssuranceFlowTest.java @@ -29,6 +29,7 @@ import org.junit.Assert; import org.junit.Before; import org.junit.Rule; import org.junit.Test; +import org.keycloak.admin.client.resource.ClientResource; import org.keycloak.authentication.authenticators.browser.PasswordFormFactory; import org.keycloak.authentication.authenticators.browser.UsernameFormFactory; import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticator; @@ -38,12 +39,15 @@ 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.ClientRepresentation; 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.admin.ApiUtil; import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude; import org.keycloak.testsuite.authentication.PushButtonAuthenticatorFactory; +import org.keycloak.testsuite.client.KeycloakTestingClient; import org.keycloak.testsuite.pages.ErrorPage; import org.keycloak.testsuite.pages.LoginUsernameOnlyPage; import org.keycloak.testsuite.pages.PasswordPage; @@ -81,53 +85,61 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest { @Override public void configureTestRealm(RealmRepresentation testRealm) { try { - Map acrLoaMap = new HashMap<>(); - acrLoaMap.put("copper", 0); - acrLoaMap.put("silver", 1); - acrLoaMap.put("gold", 2); - findTestApp(testRealm).setAttributes(Collections.singletonMap(Constants.ACR_LOA_MAP, JsonSerialization.writeValueAsString(acrLoaMap))); + findTestApp(testRealm).setAttributes(Collections.singletonMap(Constants.ACR_LOA_MAP, getAcrToLoaMappingForClient())); } catch (IOException e) { throw new RuntimeException(e); } } + private String getAcrToLoaMappingForClient() throws IOException { + Map acrLoaMap = new HashMap<>(); + acrLoaMap.put("copper", 0); + acrLoaMap.put("silver", 1); + acrLoaMap.put("gold", 2); + return JsonSerialization.writeValueAsString(acrLoaMap); + } + @Before public void setupFlow() { + configureStepUpFlow(testingClient); + } + + public static void configureStepUpFlow(KeycloakTestingClient testingClient) { 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"); - }); + .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); - }) + // 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")); + // 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); - }) + // 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")); + // 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); - }) + // simply push button for level 3 + subFlow.addAuthenticatorExecution(Requirement.REQUIRED, PushButtonAuthenticatorFactory.PROVIDER_ID); + }) - ).defineAsBrowserFlow()); + ).defineAsBrowserFlow()); } @After @@ -286,6 +298,45 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest { assertLoggedInWithAcr("gold"); } + + @Test + public void testRealmAcrLoaMapping() throws IOException { + // Setup realm acr-to-loa mapping + RealmRepresentation realmRep = testRealm().toRepresentation(); + Map acrLoaMap = new HashMap<>(); + acrLoaMap.put("realm:copper", 0); + acrLoaMap.put("realm:silver", 1); + acrLoaMap.put("realm:gold", 2); + realmRep.getAttributes().put(Constants.ACR_LOA_MAP, JsonSerialization.writeValueAsString(acrLoaMap)); + testRealm().update(realmRep); + + // Remove acr-to-loa mapping from the client. It should use realm acr-to-loa mapping + ClientResource testClient = ApiUtil.findClientByClientId(testRealm(), "test-app"); + ClientRepresentation testClientRep = testClient.toRepresentation(); + testClientRep.getAttributes().put(Constants.ACR_LOA_MAP, "{}"); + testClient.update(testClientRep); + + openLoginFormWithAcrClaim(true, "realm:gold"); + authenticateWithUsername(); + authenticateWithPassword(); + assertLoggedInWithAcr("realm:gold"); + + // Add "acr-to-loa" back to the client. Client mapping will be used instead of realm mapping + testClientRep.getAttributes().put(Constants.ACR_LOA_MAP, getAcrToLoaMappingForClient()); + testClient.update(testClientRep); + + openLoginFormWithAcrClaim(true, "realm:gold"); + assertErrorPage("Invalid parameter: claims"); + + openLoginFormWithAcrClaim(true, "gold"); + authenticateWithPassword(); + assertLoggedInWithAcr("gold"); + + // Rollback + realmRep.getAttributes().remove(Constants.ACR_LOA_MAP); + testRealm().update(realmRep); + } + public void openLoginFormWithAcrClaim(boolean essential, String... acrValues) { openLoginFormWithAcrClaim(oauth, essential, acrValues); } @@ -326,5 +377,6 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest { private void assertErrorPage(String expectedError) { Assert.assertThat(true, is(errorPage.isCurrent())); Assert.assertEquals(expectedError, errorPage.getError()); + events.clear(); } } \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCWellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCWellKnownProviderTest.java index 38bf483775..4fb8bb32f5 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCWellKnownProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCWellKnownProviderTest.java @@ -24,10 +24,12 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; import org.keycloak.OAuth2Constants; +import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.broker.provider.util.SimpleHttp; import org.keycloak.crypto.Algorithm; import org.keycloak.jose.jwe.JWEConstants; import org.keycloak.jose.jwk.JSONWebKeySet; +import org.keycloak.models.Constants; import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory; import org.keycloak.protocol.oidc.OIDCLoginProtocolService; import org.keycloak.protocol.oidc.OIDCWellKnownProviderFactory; @@ -44,6 +46,8 @@ import org.keycloak.testsuite.AbstractKeycloakTest; import org.keycloak.testsuite.Assert; import org.keycloak.testsuite.admin.AbstractAdminTest; import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude; +import org.keycloak.testsuite.forms.BrowserFlowTest; +import org.keycloak.testsuite.forms.LevelOfAssuranceFlowTest; import org.keycloak.testsuite.util.AdminClientUtil; import org.keycloak.testsuite.util.ClientManager; import org.keycloak.testsuite.util.OAuthClient; @@ -58,6 +62,7 @@ import javax.ws.rs.core.Response; import javax.ws.rs.core.UriBuilder; import java.io.IOException; import java.net.URI; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -296,6 +301,57 @@ public class OIDCWellKnownProviderTest extends AbstractKeycloakTest { } } + @Test + @AuthServerContainerExclude(REMOTE) + public void testAcrValuesSupported() throws IOException { + Client client = AdminClientUtil.createResteasyClient(); + try { + // Default values when no "acr-to-loa" mapping and no authentication flow configured + OIDCConfigurationRepresentation oidcConfig = getOIDCDiscoveryRepresentation(client, OAuthClient.AUTH_SERVER_ROOT); + Assert.assertNames(oidcConfig.getAcrValuesSupported(), "0", "1"); + + // Update authentication flow and see it uses "acr" values from it + LevelOfAssuranceFlowTest.configureStepUpFlow(testingClient); + oidcConfig = getOIDCDiscoveryRepresentation(client, OAuthClient.AUTH_SERVER_ROOT); + Assert.assertNames(oidcConfig.getAcrValuesSupported(), "0", "1", "2", "3"); + + // Configure "ACR-To-Loa" mapping and check it has both configured values and numbers from authentication flow + RealmResource testRealm = adminClient.realm("test"); + RealmRepresentation realmRep = testRealm.toRepresentation(); + Map acrToLoa = new HashMap<>(); + acrToLoa.put("poor", 0); + acrToLoa.put("silver", 1); + acrToLoa.put("gold", 2); + String acrToLoaAttr = JsonSerialization.writeValueAsString(acrToLoa); + realmRep.getAttributes().put(Constants.ACR_LOA_MAP, acrToLoaAttr); + testRealm.update(realmRep); + + oidcConfig = getOIDCDiscoveryRepresentation(client, OAuthClient.AUTH_SERVER_ROOT); + Assert.assertNames(oidcConfig.getAcrValuesSupported(), "poor", "silver", "gold", "0", "1", "2", "3"); + + // Use mappings even with values not included in the authentication flow + acrToLoa = new HashMap<>(); + acrToLoa.put("poor", 0); + acrToLoa.put("silver", 1); + acrToLoa.put("gold", 2); + acrToLoa.put("platinum", 3); + acrToLoa.put("diamond", 4); + acrToLoaAttr = JsonSerialization.writeValueAsString(acrToLoa); + realmRep.getAttributes().put(Constants.ACR_LOA_MAP, acrToLoaAttr); + testRealm.update(realmRep); + + oidcConfig = getOIDCDiscoveryRepresentation(client, OAuthClient.AUTH_SERVER_ROOT); + Assert.assertNames(oidcConfig.getAcrValuesSupported(), "poor", "silver", "gold", "platinum", "diamond", "0", "1", "2", "3"); + + // Revert realm and flow + realmRep.getAttributes().remove(Constants.ACR_LOA_MAP); + testRealm.update(realmRep); + BrowserFlowTest.revertFlows(testRealm, "browser - Level of Authentication FLow"); + } finally { + client.close(); + } + } + @Test @AuthServerContainerExclude(REMOTE) public void testDefaultProviderCustomizations() throws IOException { diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties index e0db36e53f..f4fe225423 100644 --- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties +++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties @@ -1941,7 +1941,8 @@ use-idtoken-as-detached-signature=Use ID Token as a Detached Signature use-idtoken-as-detached-signature.tooltip=This makes ID token returned from Authorization Endpoint in OIDC Hybrid flow use as a detached signature defined in FAPI 1.0 Advanced Security Profile. Therefore, this ID token does not include an authenticated user's information. acr-loa-map=ACR to LoA Mapping -acr-loa-map.tooltip=Define which ACR (Authentication Context Class Reference) value is mapped to which LoA (Level of Authentication). The ACR can be any value, whereas the LoA must be numeric. +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. The LoA typically refers to the numbers configured as levels in the conditions in the authentication flow. The ACR refers to the value used in the OIDC/SAML authorization request and returned to client in the tokens. +acr-loa-map-client.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. This is recommended to be configured at the realm level where it is shared for all the clients. If you configure at the client level, the client mapping will take precedence over the mapping from the realm level. key-not-allowed-here=Key '{{character}}' is not allowed here. diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js index 7b6aa599b5..3804cec3d2 100644 --- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js @@ -361,7 +361,7 @@ module.controller('RealmDetailCtrl', function($scope, Current, Realm, realm, ser }; }); -function genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications, url) { +function genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications, url, saveCallback, resetCallback) { $scope.realm = angular.copy(realm); $scope.serverInfo = serverInfo; $scope.registrationAllowed = $scope.realm.registrationAllowed; @@ -377,6 +377,9 @@ function genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $r }, true); $scope.save = function() { + if (saveCallback) { + saveCallback(); + } var realmCopy = angular.copy($scope.realm); console.log('updating realm...'); $scope.changed = false; @@ -390,6 +393,9 @@ function genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $r $scope.reset = function() { $scope.realm = angular.copy(oldCopy); + if (resetCallback) { + resetCallback(); + } $scope.changed = false; }; @@ -410,8 +416,54 @@ module.controller('RealmLoginSettingsCtrl', function($scope, Current, Realm, rea $scope.realm.duplicateEmailsAllowed = false; } }); + + var resetCallback = function() { + try { + $scope.acrLoaMap = JSON.parse(realm.attributes["acr.loa.map"] || "{}"); + } catch (e) { + $scope.acrLoaMap = {}; + } + } + resetCallback(); + var previousNewAcr = undefined; + var previousNewLoa = undefined; + + $scope.$watch('newAcr', function() { + var changed = $scope.newAcr != previousNewAcr; + if (changed) { + previousNewAcr = $scope.newAcr; + $scope.changed = true; + } + }, true); + $scope.$watch('newLoa', function() { + var changed = $scope.newLoa != previousNewLoa; + if (changed) { + previousNewLoa = $scope.newLoa; + $scope.changed = true; + } + }, true); + $scope.deleteAcrLoaMapping = function(acr) { + delete $scope.acrLoaMap[acr]; + $scope.changed = true; + updateRealmAcrAttribute(); + } + $scope.checkAddAcrLoaMapping = function() { + if ($scope.newAcr && $scope.newAcr.length > 0 && $scope.newLoa && $scope.newLoa.length > 0 && $scope.newLoa.match(/^[0-9]+$/)) { + console.log("Adding acrLoaMapping: " + $scope.newLoa + " : " + $scope.newAcr); + $scope.acrLoaMap[$scope.newAcr] = $scope.newLoa; + $scope.newAcr = $scope.newLoa = ""; + $scope.changed = true; + updateRealmAcrAttribute(); + } + } + + function updateRealmAcrAttribute() { + var acrLoaMapStr = JSON.stringify($scope.acrLoaMap); + console.log("Updating realm acr.loa.map attribute: " + acrLoaMapStr); + $scope.realm.attributes["acr.loa.map"] = acrLoaMapStr; + } - genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications, "/realms/" + realm.realm + "/login-settings"); + genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications, "/realms/" + realm.realm + "/login-settings", $scope.checkAddAcrLoaMapping, resetCallback); }); module.controller('RealmOtpPolicyCtrl', function($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications) { diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html b/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html index fe13783c17..91b758ab7b 100755 --- a/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html +++ b/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html @@ -950,7 +950,7 @@ - {{:: 'acr-loa-map.tooltip' | translate}} + {{:: 'acr-loa-map-client.tooltip' | translate}} diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/realm-login-settings.html b/themes/src/main/resources/theme/base/admin/resources/partials/realm-login-settings.html index c5a084ac20..ef3790108b 100755 --- a/themes/src/main/resources/theme/base/admin/resources/partials/realm-login-settings.html +++ b/themes/src/main/resources/theme/base/admin/resources/partials/realm-login-settings.html @@ -72,6 +72,26 @@ {{:: 'sslRequired.tooltip' | translate}} +
+ +
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ {{:: 'acr-loa-map.tooltip' | translate}} +