diff --git a/jans-auth-server/common/src/test/resources/testng.xml b/jans-auth-server/common/src/test/resources/testng.xml index e2dd5fc8949..1fa27b78234 100644 --- a/jans-auth-server/common/src/test/resources/testng.xml +++ b/jans-auth-server/common/src/test/resources/testng.xml @@ -1,10 +1,11 @@ - + + diff --git a/jans-auth-server/persistence-model/src/main/java/io/jans/as/persistence/model/ClientAttributes.java b/jans-auth-server/persistence-model/src/main/java/io/jans/as/persistence/model/ClientAttributes.java index cfa458061d1..0f58ba3538a 100644 --- a/jans-auth-server/persistence-model/src/main/java/io/jans/as/persistence/model/ClientAttributes.java +++ b/jans-auth-server/persistence-model/src/main/java/io/jans/as/persistence/model/ClientAttributes.java @@ -107,6 +107,41 @@ public class ClientAttributes implements Serializable { @JsonProperty("allowOfflineAccessWithoutConsent") private Boolean allowOfflineAccessWithoutConsent; + @JsonProperty("minimumAcrLevel") + private Integer minimumAcrLevel = -1; + + @JsonProperty("minimumAcrLevelAutoresolve") + private Boolean minimumAcrLevelAutoresolve; + + @JsonProperty("minimumAcrPriorityList") + private List minimumAcrPriorityList; + + public Boolean getMinimumAcrLevelAutoresolve() { + return minimumAcrLevelAutoresolve; + } + + public void setMinimumAcrLevelAutoresolve(Boolean minimumAcrLevelAutoresolve) { + this.minimumAcrLevelAutoresolve = minimumAcrLevelAutoresolve; + } + + public List getMinimumAcrPriorityList() { + if (minimumAcrPriorityList == null) minimumAcrPriorityList = new ArrayList<>(); + return minimumAcrPriorityList; + } + + public void setMinimumAcrPriorityList(List minimumAcrPriorityList) { + this.minimumAcrPriorityList = minimumAcrPriorityList; + } + + public Integer getMinimumAcrLevel() { + if (minimumAcrLevel == null) minimumAcrLevel = -1; + return minimumAcrLevel; + } + + public void setMinimumAcrLevel(Integer minimumAcrLevel) { + this.minimumAcrLevel = minimumAcrLevel; + } + public Boolean getAllowOfflineAccessWithoutConsent() { return allowOfflineAccessWithoutConsent; } @@ -378,6 +413,9 @@ public String toString() { ", publicSubjectIdentifierAttribute=" + publicSubjectIdentifierAttribute + ", redirectUrisRegex=" + redirectUrisRegex + ", allowOfflineAccessWithoutConsent=" + allowOfflineAccessWithoutConsent + + ", minimumAcrLevel=" + minimumAcrLevel + + ", minimumAcrLevelAutoresolve=" + minimumAcrLevelAutoresolve + + ", minimumAcrPriorityList=" + minimumAcrPriorityList + ", defaultPromptLogin=" + defaultPromptLogin + '}'; } diff --git a/jans-auth-server/server/src/main/java/io/jans/as/server/authorize/ws/rs/AuthorizeRestWebServiceImpl.java b/jans-auth-server/server/src/main/java/io/jans/as/server/authorize/ws/rs/AuthorizeRestWebServiceImpl.java index ef4c662e2b9..4def82ed7ad 100644 --- a/jans-auth-server/server/src/main/java/io/jans/as/server/authorize/ws/rs/AuthorizeRestWebServiceImpl.java +++ b/jans-auth-server/server/src/main/java/io/jans/as/server/authorize/ws/rs/AuthorizeRestWebServiceImpl.java @@ -331,7 +331,7 @@ private ResponseBuilder authorize(AuthzRequest authzRequest) throws AcrChangedEx authorizeRestWebServiceValidator.validate(authzRequest, responseTypes, client); authorizeRestWebServiceValidator.validatePkce(authzRequest.getCodeChallenge(), authzRequest.getRedirectUriResponse()); - authzRequestService.setDefaultAcrsIfNeeded(authzRequest, client); + authzRequestService.setAcrsIfNeeded(authzRequest); checkOfflineAccessScopes(responseTypes, prompts, client, scopes); checkResponseType(authzRequest, responseTypes, client); diff --git a/jans-auth-server/server/src/main/java/io/jans/as/server/authorize/ws/rs/AuthzRequestService.java b/jans-auth-server/server/src/main/java/io/jans/as/server/authorize/ws/rs/AuthzRequestService.java index 2a6b564f16a..8e6f8ad3313 100644 --- a/jans-auth-server/server/src/main/java/io/jans/as/server/authorize/ws/rs/AuthzRequestService.java +++ b/jans-auth-server/server/src/main/java/io/jans/as/server/authorize/ws/rs/AuthzRequestService.java @@ -1,5 +1,7 @@ package io.jans.as.server.authorize.ws.rs; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; import com.google.common.collect.Lists; import com.google.common.collect.Sets; import io.jans.as.common.model.common.User; @@ -41,6 +43,7 @@ import io.jans.as.server.model.token.HandleTokenFactory; import io.jans.as.server.par.ws.rs.ParService; import io.jans.as.server.service.*; +import io.jans.as.server.service.external.ExternalAuthenticationService; import io.jans.as.server.util.ServerUtil; import jakarta.ejb.Stateless; import jakarta.inject.Inject; @@ -58,10 +61,8 @@ import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.security.PrivateKey; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Set; +import java.util.*; +import java.util.concurrent.TimeUnit; import static io.jans.as.model.util.StringUtils.implode; import static org.apache.commons.lang3.BooleanUtils.isTrue; @@ -74,6 +75,8 @@ public class AuthzRequestService { public static final String INVALID_JWT_AUTHORIZATION_REQUEST = "Invalid JWT authorization request"; + private static final long ACR_TO_LEVEL_CACHE_LIFETIME_IN_MINUTES = 15; + private static final String ACR_TO_LEVEL_KEY = "ACR_TO_LEVEL_KEY"; @Inject private Logger log; @@ -108,6 +111,22 @@ public class AuthzRequestService { @Inject private RedirectionUriService redirectionUriService; + @Inject + private ExternalAuthenticationService externalAuthenticationService; + + private final Cache> acrToLevelCache = CacheBuilder.newBuilder() + .expireAfterWrite(ACR_TO_LEVEL_CACHE_LIFETIME_IN_MINUTES, TimeUnit.MINUTES).build(); + + public Map getAcrToLevelMap() { + Map map = acrToLevelCache.getIfPresent(ACR_TO_LEVEL_KEY); + if (map != null) { + return map; + } + map = externalAuthenticationService.acrToLevelMapping(); + acrToLevelCache.put(ACR_TO_LEVEL_KEY, map); + return map; + } + public void addDeviceSecretToSession(AuthzRequest authzRequest, SessionId sessionId) { if (BooleanUtils.isFalse(appConfiguration.getReturnDeviceSecretFromAuthzEndpoint())) { return; @@ -459,10 +478,73 @@ public void fillRedirectUriResponseforJARM(RedirectUriResponse redirectUriRespon } } - public void setDefaultAcrsIfNeeded(AuthzRequest authzRequest, Client client) { - if (StringUtils.isBlank(authzRequest.getAcrValues()) && !ArrayUtils.isEmpty(client.getDefaultAcrValues())) { - authzRequest.setAcrValues(implode(client.getDefaultAcrValues(), " ")); + public void setAcrsIfNeeded(AuthzRequest authzRequest) { + Client client = authzRequest.getClient(); + + // explicitly set acrs via getDefaultAcrValues() + if (StringUtils.isBlank(authzRequest.getAcrValues())) { + if (!ArrayUtils.isEmpty(client.getDefaultAcrValues())) { + authzRequest.setAcrValues(implode(client.getDefaultAcrValues(), " ")); + } + return; + } + + final int currentMinAcrLevel = getCurrentMinAcrLevel(authzRequest); + if (currentMinAcrLevel >= client.getAttributes().getMinimumAcrLevel()) { + return; // do nothing -> current level is enough + } + + if (BooleanUtils.isNotTrue(client.getAttributes().getMinimumAcrLevelAutoresolve())) { + log.error("Current acr level is less then minimum required. currentMinAcrLevel: {}, clientMinAcrLevel: {}", currentMinAcrLevel, client.getAttributes().getMinimumAcrLevel()); + throw new WebApplicationException(Response + .status(Response.Status.BAD_REQUEST) + .entity(errorResponseFactory.getErrorAsJson(AuthorizeErrorResponseType.INVALID_REQUEST, authzRequest.getState(), "Current acr level is less then minimum required by client")) + .build()); + } + + final Map acrToLevelMap = getAcrToLevelMap(); + if (client.getAttributes().getMinimumAcrPriorityList().isEmpty()) { // no priority list -> pick up next higher then minimum + for (Map.Entry entry : acrToLevelMap.entrySet()) { + if (currentMinAcrLevel < entry.getValue()) { + authzRequest.setAcrValues(entry.getKey()); + return; + } + } + } + + for (String acr : client.getAttributes().getMinimumAcrPriorityList()) { + final Integer acrLevel = acrToLevelMap.get(acr); + if (acrLevel != null && acrLevel >= currentMinAcrLevel) { + authzRequest.setAcrValues(acr); + return; + } + } + + log.error("Current acr level is less then minimum required by client. currentMinAcrLevel: {}, clientAttributes: {}", currentMinAcrLevel, client.getAttributes()); + throw new WebApplicationException(Response + .status(Response.Status.BAD_REQUEST) + .entity(errorResponseFactory.getErrorAsJson(AuthorizeErrorResponseType.INVALID_REQUEST, authzRequest.getState(), "Current acr level is less then minimum required by client:" + client.getClientId())) + .build()); + } + + public int getCurrentMinAcrLevel(AuthzRequest authzRequest) { + if (StringUtils.isBlank(authzRequest.getAcrValues())) { + return -1; + } + + Integer currentLevel = null; + final Map acrToLevelMap = getAcrToLevelMap(); + for (String acr : authzRequest.getAcrValuesList()) { + Integer level = acrToLevelMap.get(acr); + if (currentLevel == null) { + currentLevel = level; + continue; + } + if (level != null && level < currentLevel) { + currentLevel = level; + } } + return currentLevel != null ? currentLevel : -1; } public void createRedirectUriResponse(AuthzRequest authzRequest) { diff --git a/jans-auth-server/server/src/test/java/io/jans/as/server/authorize/ws/rs/AuthzRequestServiceTest.java b/jans-auth-server/server/src/test/java/io/jans/as/server/authorize/ws/rs/AuthzRequestServiceTest.java index d5b10badb44..fd86713b4d4 100644 --- a/jans-auth-server/server/src/test/java/io/jans/as/server/authorize/ws/rs/AuthzRequestServiceTest.java +++ b/jans-auth-server/server/src/test/java/io/jans/as/server/authorize/ws/rs/AuthzRequestServiceTest.java @@ -14,7 +14,9 @@ import io.jans.as.server.service.RedirectUriResponse; import io.jans.as.server.service.RedirectionUriService; import io.jans.as.server.service.RequestParameterService; +import io.jans.as.server.service.external.ExternalAuthenticationService; import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.WebApplicationException; import org.apache.commons.lang.StringUtils; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -23,6 +25,10 @@ import org.testng.annotations.Listeners; import org.testng.annotations.Test; +import java.util.Collections; +import java.util.HashMap; + +import static org.junit.Assert.fail; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.testng.Assert.assertEquals; @@ -70,12 +76,155 @@ public class AuthzRequestServiceTest { @Mock private RedirectionUriService redirectionUriService; + @Mock + private ExternalAuthenticationService externalAuthenticationService; + + @Test + public void setAcrsIfNeeded_whenAcrsAreNotSetButDefaultAcrsAreConfigured_shouldSetDefaultAcrs() { + Client client = new Client(); + client.setDefaultAcrValues(new String[]{"passkey"}); + + AuthzRequest authzRequest = new AuthzRequest(); + authzRequest.setClient(client); + + authzRequestService.setAcrsIfNeeded(authzRequest); + assertEquals(authzRequest.getAcrValues(), "passkey"); + } + + @Test + public void setAcrsIfNeeded_whenAcrsHasEnoughLevel_shouldRaiseNoError() { + when(externalAuthenticationService.acrToLevelMapping()).thenReturn(new HashMap() {{ + put("basic", 1); + put("otp", 5); + put("u2f", 10); + put("super_gluu", 11); + put("passkey", 20); + put("usb_fido_key", 30); + }}); + + Client client = new Client(); + client.getAttributes().setMinimumAcrLevel(14); + + AuthzRequest authzRequest = new AuthzRequest(); + authzRequest.setAcrValues("passkey"); + authzRequest.setClient(client); + + authzRequestService.setAcrsIfNeeded(authzRequest); + assertEquals(authzRequest.getAcrValues(), "passkey"); + } + + @Test + public void setAcrsIfNeeded_whenAcrsHasNoEnoughLevel_shouldRaiseError() { + when(externalAuthenticationService.acrToLevelMapping()).thenReturn(new HashMap() {{ + put("basic", 1); + put("otp", 5); + put("u2f", 10); + put("super_gluu", 11); + put("passkey", 20); + put("usb_fido_key", 30); + }}); + + Client client = new Client(); + client.getAttributes().setMinimumAcrLevel(14); + + AuthzRequest authzRequest = new AuthzRequest(); + authzRequest.setAcrValues("super_gluu"); + authzRequest.setClient(client); + + try { + authzRequestService.setAcrsIfNeeded(authzRequest); + } catch (WebApplicationException e) { + return; // successfully got error + } + + fail("Failed to throw error."); + } + + @Test + public void setAcrsIfNeeded_whenAcrsHasNoEnoughLevelButAutoResolveIsTrue_shouldRaiseNoError() { + when(externalAuthenticationService.acrToLevelMapping()).thenReturn(new HashMap() {{ + put("basic", 1); + put("otp", 5); + put("u2f", 10); + put("super_gluu", 11); + put("passkey", 20); + put("usb_fido_key", 30); + }}); + + Client client = new Client(); + client.getAttributes().setMinimumAcrLevel(14); + client.getAttributes().setMinimumAcrLevelAutoresolve(true); + + AuthzRequest authzRequest = new AuthzRequest(); + authzRequest.setAcrValues("super_gluu"); + authzRequest.setClient(client); + + authzRequestService.setAcrsIfNeeded(authzRequest); + + assertEquals(authzRequest.getAcrValues(), "passkey"); + assertTrue(externalAuthenticationService.acrToLevelMapping().get(authzRequest.getAcrValues()) > 14); + } + + @Test + public void setAcrsIfNeeded_whenAcrsHasNoEnoughLevelButAutoResolveIsTrueAndPriorityListSet_shouldHaveAcrFromPriorityListSet() { + when(externalAuthenticationService.acrToLevelMapping()).thenReturn(new HashMap() {{ + put("basic", 1); + put("otp", 5); + put("u2f", 10); + put("super_gluu", 11); + put("passkey", 20); + put("usb_fido_key", 30); + }}); + + Client client = new Client(); + client.getAttributes().setMinimumAcrLevel(14); + client.getAttributes().setMinimumAcrLevelAutoresolve(true); + client.getAttributes().setMinimumAcrPriorityList(Collections.singletonList("usb_fido_key")); + + AuthzRequest authzRequest = new AuthzRequest(); + authzRequest.setAcrValues("super_gluu"); + authzRequest.setClient(client); + + authzRequestService.setAcrsIfNeeded(authzRequest); + + assertEquals(authzRequest.getAcrValues(), "usb_fido_key"); + } + + @Test + public void setAcrsIfNeeded_whenAcrsHasNoEnoughLevelButAutoResolveIsTrueAndPriorityListSet_shouldGetErrorIfPriorityListClashWithMinimalLevel() { + when(externalAuthenticationService.acrToLevelMapping()).thenReturn(new HashMap() {{ + put("basic", 1); + put("otp", 5); + put("u2f", 10); + put("super_gluu", 11); + put("passkey", 20); + put("usb_fido_key", 30); + }}); + + Client client = new Client(); + client.getAttributes().setMinimumAcrLevel(14); + client.getAttributes().setMinimumAcrLevelAutoresolve(true); + client.getAttributes().setMinimumAcrPriorityList(Collections.singletonList("u2f")); + + AuthzRequest authzRequest = new AuthzRequest(); + authzRequest.setAcrValues("super_gluu"); + authzRequest.setClient(client); + + try { + authzRequestService.setAcrsIfNeeded(authzRequest); + } catch (WebApplicationException e) { + return; // successfully got error + } + + fail("Must fail because priority list has acr which has level lower then minumumAcrLevel"); + } + @Test public void addDeviceSecretToSession_withoutUnabledConfiguration_shouldNotGenerateDeviceSecret() { when(appConfiguration.getReturnDeviceSecretFromAuthzEndpoint()).thenReturn(false); Client client = new Client(); - client.setGrantTypes(new GrantType[] { GrantType.AUTHORIZATION_CODE, GrantType.TOKEN_EXCHANGE}); + client.setGrantTypes(new GrantType[]{GrantType.AUTHORIZATION_CODE, GrantType.TOKEN_EXCHANGE}); AuthzRequest authzRequest = new AuthzRequest(); authzRequest.setScope("openid device_sso"); @@ -93,7 +242,7 @@ public void addDeviceSecretToSession_withoutDeviceSsoScope_shouldNotGenerateDevi when(appConfiguration.getReturnDeviceSecretFromAuthzEndpoint()).thenReturn(true); Client client = new Client(); - client.setGrantTypes(new GrantType[] { GrantType.AUTHORIZATION_CODE, GrantType.TOKEN_EXCHANGE}); + client.setGrantTypes(new GrantType[]{GrantType.AUTHORIZATION_CODE, GrantType.TOKEN_EXCHANGE}); AuthzRequest authzRequest = new AuthzRequest(); authzRequest.setScope("openid"); @@ -111,7 +260,7 @@ public void addDeviceSecretToSession_withDeviceSsoScope_shouldGenerateDeviceSecr when(appConfiguration.getReturnDeviceSecretFromAuthzEndpoint()).thenReturn(true); Client client = new Client(); - client.setGrantTypes(new GrantType[] { GrantType.AUTHORIZATION_CODE, GrantType.TOKEN_EXCHANGE}); + client.setGrantTypes(new GrantType[]{GrantType.AUTHORIZATION_CODE, GrantType.TOKEN_EXCHANGE}); AuthzRequest authzRequest = new AuthzRequest(); authzRequest.setRedirectUriResponse(new RedirectUriResponse(mock(RedirectUri.class), "", mock(HttpServletRequest.class), mock(ErrorResponseFactory.class))); @@ -131,7 +280,7 @@ public void addDeviceSecretToSession_withClientWithoutTokenExchangeGrantType_sho when(appConfiguration.getReturnDeviceSecretFromAuthzEndpoint()).thenReturn(true); Client client = new Client(); - client.setGrantTypes(new GrantType[] { GrantType.AUTHORIZATION_CODE}); + client.setGrantTypes(new GrantType[]{GrantType.AUTHORIZATION_CODE}); AuthzRequest authzRequest = new AuthzRequest(); authzRequest.setRedirectUriResponse(new RedirectUriResponse(mock(RedirectUri.class), "", mock(HttpServletRequest.class), mock(ErrorResponseFactory.class)));