diff --git a/docs/script-catalog/consent_gathering/consent-gathering.md b/docs/script-catalog/consent_gathering/consent-gathering.md index 83423289cff..7b403e84dfd 100644 --- a/docs/script-catalog/consent_gathering/consent-gathering.md +++ b/docs/script-catalog/consent_gathering/consent-gathering.md @@ -9,6 +9,31 @@ tags: ## Overview OAuth 2.0 allows providers to prompt users for consent before releasing their personal information to a client (application). The standard consent process is binary: approve or deny. Using the consent gathering interception script, the consent flow can be customized to meet unique business requirements, for instance to support payment authorization, where you need to present transactional information, or where you need to step-up authentication to add security. +## Script identification during execution + +Consent script is executed during authorization step. +AS identifies consent gathering script to invoke in following order: +- if `consentGatheringScriptBackwardCompatibility` is `true` (`false` by default) - invoke first consent gathering script found in database. +- if `acrToConsentScriptNameMapping` has mapping, try to find consent script by that mapping and invoke it. +- if client has `consentGatheringScripts` that points to valid consent script, invoke it. +- if nothing from above worked try to invoke first script found in database + +`acrToConsentScriptNameMapping` is simple acr to consent script mapping +```text +acr1 - consentScript1 +acr2 - consentScript2 +.. +acrN - consentScriptN +``` + +**Agama** + +If Agama Consent is used then typically `acrToAgamaConsentFlowMapping` AS configuration property has to be used as well +to determine consent flow. +`acrToAgamaConsentFlowMapping` - The acr mapping to agama consent flow name. When AS meets acr it tries to match agama consent name and set it into session attributes under `consent_flow` name. +This makes it available for main Agama Consent script, so it knows which flow to invoke. + + ## Interface The consent gathering script implements the [ConsentGathering](https://github.com/JanssenProject/jans/blob/main/jans-core/script/src/main/java/io/jans/model/custom/script/type/authz/ConsentGatheringType.java) interface. This extends methods from the base script type in addition to adding new methods: diff --git a/jans-auth-server/model/src/main/java/io/jans/as/model/configuration/AppConfiguration.java b/jans-auth-server/model/src/main/java/io/jans/as/model/configuration/AppConfiguration.java index d51ed0c1260..57b60774442 100644 --- a/jans-auth-server/model/src/main/java/io/jans/as/model/configuration/AppConfiguration.java +++ b/jans-auth-server/model/src/main/java/io/jans/as/model/configuration/AppConfiguration.java @@ -498,6 +498,12 @@ public class AppConfiguration implements Configuration { @DocProperty(description = "The acr mappings. When AS meets key-value in map, it tries to replace 'key' with 'value' as very first thing and use that 'value' in further processing.") private Map acrMappings; + @DocProperty(description = "The acr mapping to consent script name. When AS meets acr it tries to match consent script name and invoke it during authorization. This takes higher precedence then client consent script configuration.") + private Map acrToConsentScriptNameMapping; + + @DocProperty(description = "The acr mapping to agama consent flow name. When AS meets acr it tries to match agama consent name and set it into session attributes under 'consent_flow' name. This makes it available for main Agama Consent script, so it knows which flow to invoke.") + private Map acrToAgamaConsentFlowMapping; + @DocProperty(description = "Boolean value specifying whether to enable user authentication filters") private Boolean authenticationFiltersEnabled; @@ -3617,6 +3623,26 @@ public void setAcrMappings(Map acrMappings) { this.acrMappings = acrMappings; } + public Map getAcrToConsentScriptNameMapping() { + if (acrToConsentScriptNameMapping == null) acrToConsentScriptNameMapping = new HashMap<>(); + return acrToConsentScriptNameMapping; + } + + public AppConfiguration setAcrToConsentScriptNameMapping(Map acrToConsentScriptNameMapping) { + this.acrToConsentScriptNameMapping = acrToConsentScriptNameMapping; + return this; + } + + public Map getAcrToAgamaConsentFlowMapping() { + if (acrToAgamaConsentFlowMapping == null) acrToAgamaConsentFlowMapping = new HashMap<>(); + return acrToAgamaConsentFlowMapping; + } + + public AppConfiguration setAcrToAgamaConsentFlowMapping(Map acrToAgamaConsentFlowMapping) { + this.acrToAgamaConsentFlowMapping = acrToAgamaConsentFlowMapping; + return this; + } + public EngineConfig getAgamaConfiguration() { return agamaConfiguration; } diff --git a/jans-auth-server/server/src/main/java/io/jans/as/server/authorize/ws/rs/AuthorizeAction.java b/jans-auth-server/server/src/main/java/io/jans/as/server/authorize/ws/rs/AuthorizeAction.java index 9433e274ce3..55276070de8 100644 --- a/jans-auth-server/server/src/main/java/io/jans/as/server/authorize/ws/rs/AuthorizeAction.java +++ b/jans-auth-server/server/src/main/java/io/jans/as/server/authorize/ws/rs/AuthorizeAction.java @@ -481,9 +481,10 @@ public void checkPermissionGrantedInternal() throws IOException { return; } - log.trace("Starting external consent-gathering flow"); + List acrValuesList = sessionIdService.acrValuesList(this.acrValues); + log.trace("Starting external consent-gathering flow, acrValues {} ...", acrValuesList); - boolean result = consentGatherer.configure(session.getUserDn(), clientId, state); + boolean result = consentGatherer.configure(session.getUserDn(), clientId, state, acrValuesList); if (!result) { log.error("Failed to initialize external consent-gathering flow."); permissionDenied(); diff --git a/jans-auth-server/server/src/main/java/io/jans/as/server/authorize/ws/rs/ConsentGathererService.java b/jans-auth-server/server/src/main/java/io/jans/as/server/authorize/ws/rs/ConsentGathererService.java index 4ddfca31c96..08c06cb8595 100644 --- a/jans-auth-server/server/src/main/java/io/jans/as/server/authorize/ws/rs/ConsentGathererService.java +++ b/jans-auth-server/server/src/main/java/io/jans/as/server/authorize/ws/rs/ConsentGathererService.java @@ -6,6 +6,7 @@ package io.jans.as.server.authorize.ws.rs; +import io.jans.as.common.model.session.SessionId; import io.jans.as.common.service.common.UserService; import io.jans.as.model.authorize.AuthorizeRequestParam; import io.jans.as.model.configuration.AppConfiguration; @@ -13,7 +14,6 @@ import io.jans.as.persistence.model.Scope; import io.jans.as.server.i18n.LanguageBean; import io.jans.as.server.model.authorize.ScopeChecker; -import io.jans.as.common.model.session.SessionId; import io.jans.as.server.model.config.Constants; import io.jans.as.server.service.AuthorizeService; import io.jans.as.server.service.ClientService; @@ -23,8 +23,6 @@ import io.jans.jsf2.service.FacesService; import io.jans.model.custom.script.conf.CustomScriptConfiguration; import io.jans.util.StringHelper; -import org.slf4j.Logger; - import jakarta.enterprise.context.RequestScoped; import jakarta.faces.application.FacesMessage; import jakarta.faces.context.ExternalContext; @@ -33,12 +31,11 @@ import jakarta.inject.Named; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; +import org.slf4j.Logger; + +import java.util.*; + +import static org.apache.commons.lang3.BooleanUtils.isTrue; /** * @author Yuriy Movchan Date: 10/30/2017 @@ -86,22 +83,22 @@ public class ConsentGathererService { @Inject private ScopeChecker scopeChecker; - private final Map pageAttributes = new HashMap(); + private final Map pageAttributes = new HashMap<>(); private ConsentGatheringContext context; - public boolean configure(String userDn, String clientId, String state) { + public boolean configure(String userDn, String clientId, String state, List acrValues) { final HttpServletRequest httpRequest = (HttpServletRequest) externalContext.getRequest(); final HttpServletResponse httpResponse = (HttpServletResponse) externalContext.getResponse(); final SessionId session = sessionService.getConsentSession(httpRequest, httpResponse, userDn, true); - CustomScriptConfiguration script = determineConsentScript(clientId); + CustomScriptConfiguration script = determineConsentScript(clientId, acrValues); if (script == null) { log.error("Failed to determine consent-gathering script"); return false; } - sessionService.configure(session, script.getName(), clientId, state); + sessionService.configure(session, script.getName(), clientId, state, acrValues); this.context = new ConsentGatheringContext(script.getConfigurationAttributes(), httpRequest, httpResponse, session, pageAttributes, sessionService, userService, facesService, appConfiguration); @@ -122,24 +119,59 @@ public boolean configure(String userDn, String clientId, String state) { return true; } - private CustomScriptConfiguration determineConsentScript(String clientId) { - if (appConfiguration.getConsentGatheringScriptBackwardCompatibility()) { + private CustomScriptConfiguration determineConsentScript(String clientId, List acrValues) { + log.trace("Trying to determine consent script, clientId {}, acrValues {} ...", clientId, acrValues); + + if (isTrue(appConfiguration.getConsentGatheringScriptBackwardCompatibility())) { // in 4.1 and earlier we returned default consent script + log.trace("determineConsentScript - falled back to default script {}", external.getDefaultExternalCustomScript().getName()); return external.getDefaultExternalCustomScript(); } + final CustomScriptConfiguration consentScriptByAcr = findConsentScriptByAcr(acrValues); + if (consentScriptByAcr != null) { + return consentScriptByAcr; + } + final List consentGatheringScripts = clientService.getClient(clientId).getAttributes().getConsentGatheringScripts(); final List scripts = external.getCustomScriptConfigurationsByDns(consentGatheringScripts); if (!scripts.isEmpty()) { final CustomScriptConfiguration script = Collections.max(scripts, Comparator.comparingInt(CustomScriptConfiguration::getLevel)); // flow supports single script, thus taking the one with higher level - log.debug("Determined consent gathering script `%s`", script.getName()); + log.debug("Determined consent gathering script `{}`", script.getName()); return script; } - log.debug("There no consent gathering script configured for client `%s`. Therefore taking default consent script.", clientId); + log.debug("There no consent gathering script configured for client `{}`. Therefore taking default consent script.", clientId); return external.getDefaultExternalCustomScript(); } + public CustomScriptConfiguration findConsentScriptByAcr(List acrValues) { + final Map acrToConsentScriptMap = appConfiguration.getAcrToConsentScriptNameMapping(); + if (acrToConsentScriptMap.isEmpty()) { + log.trace("findConsentScriptByAcr - 'acrToConsentScriptNameMapping' configuration property is empty"); + return null; + } + + for (Map.Entry entry : acrToConsentScriptMap.entrySet()) { + for (String acr : acrValues) { + if (entry.getKey().equalsIgnoreCase(acr)) { + final String scriptName = entry.getValue(); + log.trace("Found mapping to consent script {}, acr {}", scriptName, acr); + final CustomScriptConfiguration script = external.getCustomScriptConfigurationByName(scriptName); + if (script != null) { + log.trace("Found consent script by name {}, id {}", scriptName, script.getInum()); + return script; + } else { + log.trace("Unable to find consent script by name {}", scriptName); + } + } + } + } + + log.trace("findConsentScriptByAcr - unable to find consent script, acr: {}, acrToConsentScriptNameMapping: {}", acrValues, acrToConsentScriptMap); + return null; + } + public boolean authorize() { try { final HttpServletRequest httpRequest = (HttpServletRequest) externalContext.getRequest(); diff --git a/jans-auth-server/server/src/main/java/io/jans/as/server/authorize/ws/rs/ConsentGatheringSessionService.java b/jans-auth-server/server/src/main/java/io/jans/as/server/authorize/ws/rs/ConsentGatheringSessionService.java index e21fa3b66e6..bb82c198232 100644 --- a/jans-auth-server/server/src/main/java/io/jans/as/server/authorize/ws/rs/ConsentGatheringSessionService.java +++ b/jans-auth-server/server/src/main/java/io/jans/as/server/authorize/ws/rs/ConsentGatheringSessionService.java @@ -8,20 +8,23 @@ import io.jans.as.common.model.common.User; import io.jans.as.common.model.registration.Client; -import io.jans.as.model.util.Util; import io.jans.as.common.model.session.SessionId; +import io.jans.as.model.configuration.AppConfiguration; +import io.jans.as.model.util.Util; import io.jans.as.server.service.ClientService; import io.jans.as.server.service.CookieService; import io.jans.as.server.service.SessionIdService; import io.jans.orm.exception.EntryPersistenceException; -import org.apache.commons.lang3.StringUtils; -import org.slf4j.Logger; - import jakarta.ejb.Stateless; import jakarta.inject.Inject; import jakarta.inject.Named; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; + +import java.util.List; +import java.util.Map; /** * @author Yuriy Movchan @@ -43,6 +46,9 @@ public class ConsentGatheringSessionService { @Inject private ClientService clientService; + @Inject + private AppConfiguration appConfiguration; + public SessionId getConnectSession(HttpServletRequest httpRequest) { String cookieId = cookieService.getSessionIdFromCookie(httpRequest); log.trace("Cookie - session_id: {}", cookieId); @@ -141,14 +147,55 @@ public void setStep(int step, SessionId session) { session.getSessionAttributes().put("step", Integer.toString(step)); } - public void configure(SessionId session, String scriptName, String clientId, String state) { + public String getAcr(SessionId session) { + return session.getSessionAttributes().get("acr"); + } + + public void setAcr(List acrValues, SessionId session) { + session.getSessionAttributes().put("acr", Util.listAsString(acrValues)); + } + + public String getConsentFlow(SessionId session) { + return session.getSessionAttributes().get("consent_flow"); + } + + public void setConsentFlow(String consentFlow, SessionId session) { + session.getSessionAttributes().put("consent_flow", consentFlow); + } + + public void configure(SessionId session, String scriptName, String clientId, String state, List acrValues) { setStep(1, session); setScriptName(session, scriptName); + setAcr(acrValues, session); + setConsentFlow(determineConsentFlow(acrValues), session); setClientId(session, clientId); persist(session); } + private String determineConsentFlow(List acrValues) { + if (acrValues == null || acrValues.isEmpty()) { + log.debug("determineConsentFlow - 'acrValues' is empty, return null for 'consent_flow'"); + return null; + } + + final Map acrToConsent = appConfiguration.getAcrToConsentScriptNameMapping(); + if (acrToConsent == null || acrToConsent.isEmpty()) { + log.debug("determineConsentFlow - 'acrToConsentScriptNameMapping' configuration property is empty, return null for 'consent_flow'"); + return null; + } + + for (String acr : acrValues) { + final String consentFlow = acrToConsent.get(acr); + if (StringUtils.isNotBlank(consentFlow)) { + log.debug("determineConsentFlow - found consent_flow: {} for acr: {}", consentFlow, acr); + return consentFlow; + } + } + log.debug("determineConsentFlow - unable to find any match for acr: {}, acrToConsentScriptNameMapping: {}", acrValues, acrToConsent); + return null; + } + public boolean isStepPassed(SessionId session, Integer step) { return Boolean.parseBoolean(session.getSessionAttributes().get(String.format("consent_step_passed_%d", step))); } diff --git a/jans-auth-server/server/src/test/java/io/jans/as/server/authorize/ws/rs/ConsentGathererServiceTest.java b/jans-auth-server/server/src/test/java/io/jans/as/server/authorize/ws/rs/ConsentGathererServiceTest.java new file mode 100644 index 00000000000..3286573f2e7 --- /dev/null +++ b/jans-auth-server/server/src/test/java/io/jans/as/server/authorize/ws/rs/ConsentGathererServiceTest.java @@ -0,0 +1,103 @@ +package io.jans.as.server.authorize.ws.rs; + +import com.google.common.collect.Lists; +import io.jans.as.common.service.common.UserService; +import io.jans.as.model.configuration.AppConfiguration; +import io.jans.as.server.i18n.LanguageBean; +import io.jans.as.server.model.authorize.ScopeChecker; +import io.jans.as.server.service.AuthorizeService; +import io.jans.as.server.service.ClientService; +import io.jans.as.server.service.SessionIdService; +import io.jans.as.server.service.external.ExternalConsentGatheringService; +import io.jans.jsf2.service.FacesService; +import io.jans.model.custom.script.conf.CustomScriptConfiguration; +import io.jans.model.custom.script.model.CustomScript; +import io.jans.model.custom.script.type.authz.DummyConsentGatheringType; +import jakarta.faces.context.ExternalContext; +import jakarta.faces.context.FacesContext; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.testng.MockitoTestNGListener; +import org.slf4j.Logger; +import org.testng.annotations.Listeners; +import org.testng.annotations.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.testng.AssertJUnit.*; + +/** + * @author Yuriy Z + */ +@Listeners(MockitoTestNGListener.class) +public class ConsentGathererServiceTest { + + @InjectMocks + private ConsentGathererService consentGathererService; + + @Mock + private Logger log; + + @Mock + private ExternalConsentGatheringService external; + + @Mock + private AppConfiguration appConfiguration; + + @Mock + private FacesContext facesContext; + + @Mock + private ExternalContext externalContext; + + @Mock + private FacesService facesService; + + @Mock + private LanguageBean languageBean; + + @Mock + private ConsentGatheringSessionService sessionService; + + @Mock + private UserService userService; + + @Mock + private AuthorizeService authorizeService; + + @Mock + private ClientService clientService; + + @Mock + private SessionIdService sessionIdService; + + @Mock + private ScopeChecker scopeChecker; + + @Test + public void findConsentScriptByAcr_whenConfigurationMappingIsNotSet_shouldReturnNull() { + final CustomScriptConfiguration agama = consentGathererService.findConsentScriptByAcr(Lists.newArrayList("agama")); + assertNull(agama); + } + + @Test + public void findConsentScriptByAcr_whenConfigurationMappingIsPresent_shouldReturnScript() { + final CustomScript agamaConsentScript = new CustomScript(); + agamaConsentScript.setName("consentScript"); + CustomScriptConfiguration agamaConsent = new CustomScriptConfiguration(agamaConsentScript, new DummyConsentGatheringType(), new HashMap<>()); + + String acr = "agama"; + Map configuration = new HashMap<>(); + configuration.put(acr, "consentScript"); + + when(appConfiguration.getAcrToConsentScriptNameMapping()).thenReturn(configuration); + when(external.getCustomScriptConfigurationByName(anyString())).thenReturn(agamaConsent); + + final CustomScriptConfiguration agama = consentGathererService.findConsentScriptByAcr(Lists.newArrayList(acr)); + assertNotNull(agama); + assertEquals("consentScript", agama.getName()); + } +}