diff --git a/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/main/java/org/wso2/carbon/identity/application/authentication/framework/cache/SessionContextCache.java b/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/main/java/org/wso2/carbon/identity/application/authentication/framework/cache/SessionContextCache.java index 93573f7c72d8..e8589af2aa5c 100644 --- a/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/main/java/org/wso2/carbon/identity/application/authentication/framework/cache/SessionContextCache.java +++ b/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/main/java/org/wso2/carbon/identity/application/authentication/framework/cache/SessionContextCache.java @@ -82,6 +82,25 @@ public void addToCache(SessionContextCacheKey key, SessionContextCacheEntry entr optimizeAndStoreSessionData(key, entry); } + /** + * Add the given session context entry to the cache only, without persisting it to the session data store. + * + * @param key Key which the cache entry is indexed by. + * @param entry Value to be stored in the cache. + * @param loginTenantDomain Login tenant domain under which the cache entry is stored. + */ + public void addToCacheWithoutPersisting(SessionContextCacheKey key, SessionContextCacheEntry entry, + String loginTenantDomain) { + + if (log.isDebugEnabled()) { + log.debug("Adding session context to cache without updating the session data store corresponding " + + "to the key : " + key.getContextId() + " with accessed time " + entry.getAccessedTime() + + " and validity time " + entry.getValidityPeriod()); + } + entry.setAccessedTime(); + super.addToCache(key, entry, resolveLoginTenantDomain(loginTenantDomain)); + } + /** * Add a cache entry during a READ operation. *

diff --git a/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/main/java/org/wso2/carbon/identity/application/authentication/framework/handler/request/impl/DefaultAuthenticationRequestHandler.java b/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/main/java/org/wso2/carbon/identity/application/authentication/framework/handler/request/impl/DefaultAuthenticationRequestHandler.java index ca5ef1da9c59..083fe202eee3 100644 --- a/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/main/java/org/wso2/carbon/identity/application/authentication/framework/handler/request/impl/DefaultAuthenticationRequestHandler.java +++ b/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/main/java/org/wso2/carbon/identity/application/authentication/framework/handler/request/impl/DefaultAuthenticationRequestHandler.java @@ -65,6 +65,7 @@ import org.wso2.carbon.identity.core.URLBuilderException; import org.wso2.carbon.identity.core.util.IdentityTenantUtil; import org.wso2.carbon.identity.core.util.IdentityUtil; +import org.wso2.carbon.identity.organization.management.service.exception.OrganizationManagementException; import org.wso2.carbon.idp.mgt.IdentityProviderManagementException; import org.wso2.carbon.idp.mgt.IdentityProviderManager; import org.wso2.carbon.idp.mgt.util.IdPManagementUtil; @@ -81,8 +82,10 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; @@ -512,27 +515,17 @@ protected void concludeFlow(HttpServletRequest request, HttpServletResponse resp SessionContext loadedSessionContext = FrameworkUtils.getSessionContextFromCache( sessionContextKey, context.getLoginTenantDomain()); if (loadedSessionContext != null) { - if (context.isSharedAppLogin()) { - String authenticatedOrgId = - context.getOrganizationLoginData().getAccessingOrganization().getId(); - if (MapUtils.isNotEmpty(loadedSessionContext.getAuthenticatedOrgData()) && - loadedSessionContext.getAuthenticatedOrgData().get(authenticatedOrgId) != null) { - sessionContext = loadedSessionContext; - } - } else if (context.isOrgApplicationLogin()) { - String accessingOrgId = PrivilegedCarbonContext.getThreadLocalCarbonContext() - .getAccessingOrganizationId(); - if (MapUtils.isNotEmpty(loadedSessionContext.getAuthenticatedOrgData()) && - loadedSessionContext.getAuthenticatedOrgData().get(accessingOrgId) != null) { - sessionContext = loadedSessionContext; - } - } else { + boolean isSubOrgLogin = context.isSharedAppLogin() || context.isOrgApplicationLogin(); + boolean isPreviousSessionApplicable = !isSubOrgLogin + || isAuthenticatedOrgSessionsApplicable(context, loadedSessionContext); + + if (isPreviousSessionApplicable) { sessionContext = loadedSessionContext; - } - if (sessionContext == null) { + } else { if (log.isDebugEnabled()) { - log.debug("Previous session context is not applicable for the current authentication." + - " Hence, a new session context will be created for the authentication flow."); + log.debug("Previous session context is not applicable for the current " + + "authentication. Hence, a new session context will be created for the " + + "authentication flow."); } FrameworkUtils.removeSessionContextFromCache(sessionContextKey, context.getLoginTenantDomain()); @@ -557,16 +550,31 @@ protected void concludeFlow(HttpServletRequest request, HttpServletResponse resp } AuthenticatedOrgData authenticatedOrgData = sessionContext.getAuthenticatedOrgData().get(organizationId); - authenticatedOrgData.getAuthenticatedSequences() - .put(appConfig.getApplicationName(), sequenceConfig); - authenticatedOrgData.getAuthenticatedIdPs().putAll(context.getCurrentAuthenticatedIdPs()); - if (!context.isPassiveAuthenticate()) { - setAuthenticatedIDPsOfApp(authenticatedOrgData, context.getCurrentAuthenticatedIdPs(), - appConfig.getApplicationName()); - } if (context.isSharedAppLogin()) { sessionContext.setAuthenticatedSharedAppOrgId(organizationId); } + if (authenticatedOrgData != null) { + authenticatedOrgData.getAuthenticatedSequences() + .put(appConfig.getApplicationName(), sequenceConfig); + authenticatedOrgData.getAuthenticatedIdPs().putAll(context.getCurrentAuthenticatedIdPs()); + if (!context.isPassiveAuthenticate()) { + setAuthenticatedIDPsOfApp(authenticatedOrgData, context.getCurrentAuthenticatedIdPs(), + appConfig.getApplicationName()); + } + } else { + // currently accessing org is not previously authenticated. + AuthenticatedOrgData accessingOrgAuthenticatedOrgData = new AuthenticatedOrgData(); + accessingOrgAuthenticatedOrgData.getAuthenticatedSequences().put(appConfig.getApplicationName(), + sequenceConfig); + // Consider both the previous and the current authenticated IdPs when an IdP with the same + // name is present in both. + accessingOrgAuthenticatedOrgData.setAuthenticatedIdPs(mergeAuthenticatedIdPs( + context.getPreviousAuthenticatedIdPs(), context.getCurrentAuthenticatedIdPs())); + setAuthenticatedIDPsOfApp(accessingOrgAuthenticatedOrgData, + context.getCurrentAuthenticatedIdPs(), appConfig.getApplicationName()); + accessingOrgAuthenticatedOrgData.setRememberMe(context.isRememberMe()); + sessionContext.getAuthenticatedOrgData().put(organizationId, accessingOrgAuthenticatedOrgData); + } } else { sessionContext.getAuthenticatedSequences().put(appConfig.getApplicationName(), sequenceConfig); sessionContext.getAuthenticatedIdPs().putAll(context.getCurrentAuthenticatedIdPs()); @@ -658,8 +666,8 @@ protected void concludeFlow(HttpServletRequest request, HttpServletResponse resp request, response, context); // TODO add to cache? // store again. when replicate cache is used. this may be needed. - FrameworkUtils.addSessionContextToCache(sessionContextKey, sessionContext, applicationTenantDomain, - context.getLoginTenantDomain(), organizationId); + addSessionContextToCache(sessionContextKey, sessionContext, + applicationTenantDomain, context, organizationId); // Since the session context is already available, audit log will be added with updated details. addAuditLogs(SessionMgtConstants.UPDATE_SESSION_ACTION, authenticationResult.getSubject(), sessionContextKey, FrameworkUtils.getCorrelation(), @@ -856,6 +864,47 @@ protected void concludeFlow(HttpServletRequest request, HttpServletResponse resp sendResponse(request, response, context); } + /** + * Adds the session context to the cache against the login tenant domain (also persisting it to the session data + * store) and additionally caches it against all other tenant domains the session spans across. + * + * @param sessionContextKey Session context cache key. + * @param sessionContext Session context to be cached. + * @param applicationTenantDomain Application tenant domain used to resolve session timeout configurations. + * @param context Authentication context. + * @param organizationId Organization id used to resolve authenticated sequences for cache cleanup. + */ + private void addSessionContextToCache(String sessionContextKey, SessionContext sessionContext, + String applicationTenantDomain, + AuthenticationContext context, String organizationId) { + + String loginTenantDomain = context.getLoginTenantDomain(); + FrameworkUtils.addSessionContextToCache(sessionContextKey, sessionContext, applicationTenantDomain, + loginTenantDomain, organizationId); + Set authenticatedOrganizations = new HashSet<>(); + if (MapUtils.isNotEmpty(sessionContext.getAuthenticatedOrgData())) { + for (String authenticatedOrgId : sessionContext.getAuthenticatedOrgData().keySet()) { + try { + authenticatedOrganizations.add(FrameworkServiceDataHolder.getInstance() + .getOrganizationManager().resolveTenantDomain(authenticatedOrgId)); + } catch (OrganizationManagementException e) { + log.error("Error while resolving the tenant domain of the organization with id: " + + authenticatedOrgId, e); + } + } + } + + if (context.getOrganizationLoginData() != null && StringUtils.isNotBlank( + context.getOrganizationLoginData().getRootOrganizationTenantDomain())) { + authenticatedOrganizations.add(context.getOrganizationLoginData().getRootOrganizationTenantDomain()); + } + authenticatedOrganizations.remove(loginTenantDomain); + for (String tenantDomain : authenticatedOrganizations) { + FrameworkUtils.addSessionContextToCacheWithoutPersisting(sessionContextKey, sessionContext, + applicationTenantDomain, tenantDomain, organizationId); + } + } + private void storeFedAuthSessionMapping(String sessionContextKey, AuthHistory authHistory) throws UserSessionException { @@ -1387,6 +1436,71 @@ private void setAuthenticatedIDPsOfApp(AuthenticatedOrgData authenticatedOrgData authenticatedOrgData.setAuthenticatedIdPsOfApp(applicationName, authenticatedIdPDataMap); } + /** + * Merges the given authenticated IdP data maps into a single map + * adding only the authenticators which are not already present. + * + * @param previousAuthenticatedIdPs The authenticated IdP data to be used as the base. + * @param currentAuthenticatedIdPs The authenticated IdP data to be merged into the base. + * @return A new map containing the merged authenticated IdP data. + * @throws FrameworkException If an error occurs while cloning the authenticated IdP data. + */ + private Map mergeAuthenticatedIdPs( + Map previousAuthenticatedIdPs, + Map currentAuthenticatedIdPs) throws FrameworkException { + + Map mergedAuthenticatedIdPs = new HashMap<>(); + mergeAuthenticatedIdPsInto(mergedAuthenticatedIdPs, previousAuthenticatedIdPs); + mergeAuthenticatedIdPsInto(mergedAuthenticatedIdPs, currentAuthenticatedIdPs); + return mergedAuthenticatedIdPs; + } + + /** + * Merges the authenticated IdP data of the given source map into the target merged map. When an IdP with the same + * name is already present in the merged map, only the authenticators which are not already present are added to + * the existing entry. + * + * @param mergedAuthenticatedIdPs The target map to merge into. + * @param authenticatedIdPs The source authenticated IdP data to merge. + * @throws FrameworkException If an error occurs while cloning the authenticated IdP data. + */ + private void mergeAuthenticatedIdPsInto(Map mergedAuthenticatedIdPs, + Map authenticatedIdPs) + throws FrameworkException { + + if (MapUtils.isEmpty(authenticatedIdPs)) { + return; + } + + for (Map.Entry entry : authenticatedIdPs.entrySet()) { + String idpName = entry.getKey(); + AuthenticatedIdPData authenticatedIdPData = entry.getValue(); + AuthenticatedIdPData existingAuthenticatedIdPData = mergedAuthenticatedIdPs.get(idpName); + if (existingAuthenticatedIdPData == null) { + try { + mergedAuthenticatedIdPs.put(idpName, (AuthenticatedIdPData) authenticatedIdPData.clone()); + } catch (CloneNotSupportedException e) { + throw new FrameworkException("Error while cloning AuthenticatedIdPData object.", e); + } + } else { + // An IdP with the same name is already added. Add only the authenticators which are not already + // present in the existing entry. + List existingAuthenticators = existingAuthenticatedIdPData.getAuthenticators(); + List newAuthenticators = authenticatedIdPData.getAuthenticators(); + if (CollectionUtils.isNotEmpty(newAuthenticators)) { + for (AuthenticatorConfig newAuthenticator : newAuthenticators) { + boolean alreadyPresent = existingAuthenticators != null && existingAuthenticators.stream() + .anyMatch(existing -> StringUtils.equals(existing.getName(), + newAuthenticator.getName())); + if (!alreadyPresent) { + existingAuthenticatedIdPData.addAuthenticator(newAuthenticator); + } + } + } + } + } + } + private void addAuditLogs(String sessionAction, AuthenticatedUser authenticatedUser, String sessionKey, String traceId, Long lastAccessedTimestamp, boolean isRememberMe) { @@ -1446,4 +1560,56 @@ private List getFederatedAuthenticatorName(List federate } return federatedTokens.stream().map(FederatedToken::getIdp).collect(Collectors.toList()); } + + /** + * Determines whether the organization sessions held in an already existing session context are applicable to + * the current organization login, and can therefore be reused instead of creating a new session context. + * + * @param context Authentication context of the current authentication flow. + * @param sessionContext Existing session context loaded from the cache. + * @return {@code true} if the organization sessions in the existing session context are applicable to the + * current login; {@code false} otherwise. + */ + private boolean isAuthenticatedOrgSessionsApplicable(AuthenticationContext context, + SessionContext sessionContext) { + + Map authenticatedOrgDataMap = sessionContext.getAuthenticatedOrgData(); + if (MapUtils.isEmpty(authenticatedOrgDataMap)) { + return false; + } + + AuthenticatedUser currentAuthenticatedUser = context.getSequenceConfig().getAuthenticatedUser(); + String accessingOrgId = PrivilegedCarbonContext.getThreadLocalCarbonContext().getAccessingOrganizationId(); + if (context.isSharedAppLogin()) { + accessingOrgId = context.getOrganizationLoginData().getAccessingOrganization().getId(); + } + + if (accessingOrgId == null) { + return false; + } + + if (!currentAuthenticatedUser.isSharedUser() && + sessionContext.getAuthenticatedOrgData().get(accessingOrgId) == null) { + return false; + } + + try { + String currentAuthenticatedUserId = currentAuthenticatedUser.getUserId(); + for (AuthenticatedOrgData authenticatedOrgData : authenticatedOrgDataMap.values()) { + for (SequenceConfig sequenceConfig : authenticatedOrgData.getAuthenticatedSequences().values()) { + if (sequenceConfig != null && sequenceConfig.getAuthenticatedUser() != null && + !StringUtils.equals(currentAuthenticatedUserId, + sequenceConfig.getAuthenticatedUser().getUserId())) { + return false; + } + } + } + } catch (UserIdNotFoundException e) { + if (log.isDebugEnabled()) { + log.debug("Error while resolving userId for user: " + + currentAuthenticatedUser.getLoggableMaskedUserId()); + } + } + return true; + } } diff --git a/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/main/java/org/wso2/carbon/identity/application/authentication/framework/handler/request/impl/DefaultRequestCoordinator.java b/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/main/java/org/wso2/carbon/identity/application/authentication/framework/handler/request/impl/DefaultRequestCoordinator.java index eb052f7f6976..ff2635579c89 100644 --- a/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/main/java/org/wso2/carbon/identity/application/authentication/framework/handler/request/impl/DefaultRequestCoordinator.java +++ b/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/main/java/org/wso2/carbon/identity/application/authentication/framework/handler/request/impl/DefaultRequestCoordinator.java @@ -83,6 +83,8 @@ import org.wso2.carbon.identity.core.model.IdentityErrorMsgContext; import org.wso2.carbon.identity.core.util.IdentityTenantUtil; import org.wso2.carbon.identity.core.util.IdentityUtil; +import org.wso2.carbon.identity.organization.management.organization.user.sharing.OrganizationUserSharingService; +import org.wso2.carbon.identity.organization.management.organization.user.sharing.models.UserAssociation; import org.wso2.carbon.identity.organization.management.service.exception.OrganizationManagementException; import org.wso2.carbon.identity.organization.management.service.util.OrganizationManagementUtil; import org.wso2.carbon.user.api.Tenant; @@ -106,6 +108,7 @@ import java.util.Collections; import java.util.Enumeration; import java.util.HashMap; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Optional; @@ -1225,26 +1228,35 @@ protected void findPreviousAuthenticatedSession(HttpServletRequest request, Auth if (previousSessionOrgId.isPresent()) { orgIdForSessionDataLookup = previousSessionOrgId.get(); sessionContext = loadedSessionContext; - } - - // Handle session context loading for root sessions if organization sessions are not applicable. - boolean hasRootSessionContext = (loadedSessionContext.getAuthenticatedIdPs() != null); - if (previousSessionOrgId.isEmpty() && hasRootSessionContext) { - boolean invalidSessionInCurrentContext = false; - if (applicationConfig != null && !applicationConfig.isSaaSApp() - && loadedSessionContext.getProperty(FrameworkUtils.TENANT_DOMAIN) != null) { - /* - Consider the session context as invalid if the tenant domain in the session context is - different from the login tenant domain in the current context for non-SaaS applications. - */ - invalidSessionInCurrentContext = !StringUtils.equals( - loadedSessionContext.getProperty(FrameworkUtils.TENANT_DOMAIN).toString(), - context.getLoginTenantDomain()); - } - if (invalidSessionInCurrentContext) { - request.setAttribute(FrameworkConstants.REMOVE_COMMONAUTH_COOKIE, true); - } else { - sessionContext = loadedSessionContext; + } else if (context.isOrgApplicationLogin() + && MapUtils.isNotEmpty(loadedSessionContext.getAuthenticatedOrgData())) { + // The accessing org has no session in the loaded session context. Reuse the authenticators + // already satisfied in other organizations to honor SSO for the current organization + // application login. + String accessingOrgId = + PrivilegedCarbonContext.getThreadLocalCarbonContext().getAccessingOrganizationId(); + populateContextWithPreviousAuthenticatedOrganizationSessions(accessingOrgId, context, + effectiveSequence, loadedSessionContext); + } else { + // Handle session context loading for root sessions if organization sessions are not applicable. + boolean hasRootSessionContext = (loadedSessionContext.getAuthenticatedIdPs() != null); + if (hasRootSessionContext) { + boolean invalidSessionInCurrentContext = false; + if (applicationConfig != null && !applicationConfig.isSaaSApp() + && loadedSessionContext.getProperty(FrameworkUtils.TENANT_DOMAIN) != null) { + /* + Consider the session context as invalid if the tenant domain in the session context is + different from the login tenant domain in the current context for non-SaaS applications. + */ + invalidSessionInCurrentContext = !StringUtils.equals( + loadedSessionContext.getProperty(FrameworkUtils.TENANT_DOMAIN).toString(), + context.getLoginTenantDomain()); + } + if (invalidSessionInCurrentContext) { + request.setAttribute(FrameworkConstants.REMOVE_COMMONAUTH_COOKIE, true); + } else { + sessionContext = loadedSessionContext; + } } } } @@ -1263,6 +1275,23 @@ protected void findPreviousAuthenticatedSession(HttpServletRequest request, Auth context.setSequenceConfig(effectiveSequence); } + /** + * Checks whether the request carries any organization discovery input parameter. + * + * @param request The HTTP servlet request. + * @return {@code true} if at least one organization discovery parameter is present in the request. + */ + private boolean hasOrganizationDiscoveryParameters(HttpServletRequest request) { + + return StringUtils.isNotEmpty(request.getParameter(FrameworkConstants.OrgDiscoveryInputParameters.ORG_ID)) + || StringUtils.isNotEmpty( + request.getParameter(FrameworkConstants.OrgDiscoveryInputParameters.ORG_HANDLE)) + || StringUtils.isNotEmpty( + request.getParameter(FrameworkConstants.OrgDiscoveryInputParameters.ORG_NAME)) + || StringUtils.isNotEmpty( + request.getParameter(FrameworkConstants.OrgDiscoveryInputParameters.LOGIN_HINT)); + } + protected void findPreviousOrganizationSession(HttpServletRequest request, AuthenticationContext context) throws FrameworkException { @@ -1284,6 +1313,12 @@ protected void findPreviousOrganizationSession(HttpServletRequest request, Authe if (accessingOrgId != null) { if (loadedSessionContext.getAuthenticatedOrgData().get(accessingOrgId) != null) { sessionContext = loadedSessionContext; + } else { + // The accessing org has no session in the loaded session context. Reuse the authenticators + // already satisfied in other organizations to honor SSO for the current organization + // application login. + populateContextWithPreviousAuthenticatedOrganizationSessions(accessingOrgId, + context, effectiveSequence, loadedSessionContext); } } } @@ -1300,6 +1335,178 @@ protected void findPreviousOrganizationSession(HttpServletRequest request, Authe context.setSequenceConfig(effectiveSequence); } + /** + * Updates the effective sequence config to reflect the authenticators which the shared user has already + * authenticated with in other sub-organizations, providing SSO capabilities for shared user logins across + * sub-organizations. + * + * @param context The authentication context. + * @param effectiveSequence The effective sequence config of the current authentication flow. + * @param loadedSessionContext The loaded session context holding the authenticated organization data. + */ + private void populateContextWithPreviousAuthenticatedOrganizationSessions(String accessingOrgId, + AuthenticationContext context, + SequenceConfig effectiveSequence, + SessionContext loadedSessionContext) { + + Map authenticatedOrgData = loadedSessionContext.getAuthenticatedOrgData(); + if (MapUtils.isEmpty(authenticatedOrgData)) { + if (log.isDebugEnabled()) { + log.debug("No authenticated organization data found in the session context. Skipping the " + + "population of the sequence config with previously authenticated organization sessions."); + } + return; + } + + if (!isAuthenticatedUserSharedToAccessingOrg(loadedSessionContext, accessingOrgId)) { + if (log.isDebugEnabled()) { + log.debug("The authenticated user is not shared to the accessing organization: " + accessingOrgId + + ". Skipping the population of the sequence config with previously authenticated " + + "organization sessions."); + } + return; + } + + Map previousAuthenticatedIdPs = new HashMap<>(); + + for (StepConfig stepConfig : effectiveSequence.getStepMap().values()) { + for (Map.Entry orgDataEntry : authenticatedOrgData.entrySet()) { + Map orgAuthenticatedIdPs = orgDataEntry.getValue().getAuthenticatedIdPs(); + Map authenticatedStepIdPs = + FrameworkUtils.getAuthenticatedStepIdPs(stepConfig, orgAuthenticatedIdPs); + if (authenticatedStepIdPs.isEmpty()) { + continue; + } + + Map.Entry authenticatedStepIdP = + authenticatedStepIdPs.entrySet().iterator().next(); + String authenticatedIdPName = authenticatedStepIdP.getKey(); + AuthenticatorConfig authenticatedAuthenticator = authenticatedStepIdP.getValue(); + AuthenticatedIdPData authenticatedIdPData = orgAuthenticatedIdPs.get(authenticatedIdPName); + + stepConfig.setAuthenticatedAutenticator(authenticatedAuthenticator); + stepConfig.setAuthenticatedAuthenticatorName(authenticatedAuthenticator.getName()); + stepConfig.setAuthenticatedIdP(authenticatedIdPName); + + AuthenticatedUser authenticatedUser = authenticatedIdPData.getUser(); + // Switch the accessing org of the authenticated user to the current accessing org. + // Copying to avoid modifying the cache entry. + boolean updateAccessingOrganization = authenticatedUser != null + && StringUtils.isNotBlank(authenticatedUser.getAccessingOrganization()); + if (updateAccessingOrganization) { + authenticatedUser = new AuthenticatedUser(authenticatedUser); + authenticatedUser.setAccessingOrganization(accessingOrgId); + + // Clone the authenticated IdP data and switch its user's accessing org similarly. + try { + AuthenticatedIdPData clonedAuthenticatedIdPData = + (AuthenticatedIdPData) authenticatedIdPData.clone(); + clonedAuthenticatedIdPData.setUser(authenticatedUser); + authenticatedIdPData = clonedAuthenticatedIdPData; + } catch (CloneNotSupportedException e) { + log.error("Error while cloning the authenticated IdP data of the IdP: " + + authenticatedIdPName + ". Proceeding with the loaded authenticated IdP data.", e); + } + } + stepConfig.setAuthenticatedUser(authenticatedUser); + + // Carry over the authenticated IdP data so the framework can honor the SSO downstream. + AuthenticatedIdPData existingAuthenticatedIdPData = previousAuthenticatedIdPs.get(authenticatedIdPName); + if (existingAuthenticatedIdPData == null) { + previousAuthenticatedIdPs.put(authenticatedIdPName, authenticatedIdPData); + } else { + // An entry for the same IdP is already carried over (from another organization). Append only + // the authenticators which are not already present in the existing entry. + List existingAuthenticators = + existingAuthenticatedIdPData.getAuthenticators(); + List newAuthenticators = authenticatedIdPData.getAuthenticators(); + if (CollectionUtils.isNotEmpty(newAuthenticators)) { + for (AuthenticatorConfig newAuthenticator : newAuthenticators) { + boolean alreadyPresent = existingAuthenticators != null && existingAuthenticators.stream() + .anyMatch(existing -> StringUtils.equals(existing.getName(), + newAuthenticator.getName())); + if (!alreadyPresent) { + existingAuthenticatedIdPData.addAuthenticator(newAuthenticator); + } + } + } + } + + if (log.isDebugEnabled()) { + log.debug(String.format("Marking step %d as authenticated with the authenticator '%s' of the " + + "IdP '%s' from the previously authenticated organization '%s'.", + stepConfig.getOrder(), authenticatedAuthenticator.getName(), authenticatedIdPName, + orgDataEntry.getKey())); + } + break; + } + } + + if (!previousAuthenticatedIdPs.isEmpty()) { + context.setPreviousSessionFound(true); + Map existingPreviousAuthenticatedIdPs = + context.getPreviousAuthenticatedIdPs(); + if (MapUtils.isNotEmpty(existingPreviousAuthenticatedIdPs)) { + existingPreviousAuthenticatedIdPs.putAll(previousAuthenticatedIdPs); + } else { + context.setPreviousAuthenticatedIdPs(previousAuthenticatedIdPs); + } + } + } + + /** + * Checks whether the authenticated user held in the loaded session context's authenticated organization data is + * shared to the accessing organization. + * + * @param loadedSessionContext The loaded session context holding the authenticated organization data. + * @param accessingOrgId The organization ID the user is currently accessing. + * @return {@code true} if the authenticated user is shared to the accessing organization, {@code false} otherwise. + */ + private boolean isAuthenticatedUserSharedToAccessingOrg(SessionContext loadedSessionContext, + String accessingOrgId) { + + if (StringUtils.isBlank(accessingOrgId)) { + return false; + } + + AuthenticatedUser authenticatedUser = null; + Iterator authenticatedOrgDataIterator = + loadedSessionContext.getAuthenticatedOrgData().values().iterator(); + if (authenticatedOrgDataIterator.hasNext()) { + AuthenticatedOrgData firstAuthenticatedOrgData = authenticatedOrgDataIterator.next(); + for (SequenceConfig sequenceConfig : firstAuthenticatedOrgData.getAuthenticatedSequences().values()) { + if (sequenceConfig != null && sequenceConfig.getAuthenticatedUser() != null) { + authenticatedUser = sequenceConfig.getAuthenticatedUser(); + break; + } + } + } + + if (authenticatedUser == null) { + if (log.isDebugEnabled()) { + log.debug("No authenticated user found in the authenticated organization data of the loaded " + + "session context."); + } + return false; + } + + try { + OrganizationUserSharingService organizationUserSharingService = + FrameworkServiceDataHolder.getInstance().getOrganizationUserSharingService(); + UserAssociation userAssociation = organizationUserSharingService + .getUserAssociationOfAssociatedUserByOrgId(authenticatedUser.getUserId(), accessingOrgId); + return userAssociation != null; + } catch (OrganizationManagementException e) { + log.error("Error while resolving the shared user association of the authenticated user for the " + + "accessing organization: " + accessingOrgId, e); + return false; + } catch (UserIdNotFoundException e) { + log.error("Authenticated user ID not found while resolving the shared user association for the " + + "accessing organization: " + accessingOrgId, e); + return false; + } + } + /** * Processes ACR (Authentication Context Class Reference) values from the request and adds them to the * authentication context. @@ -1424,6 +1631,10 @@ private Optional handleOrganizationSessions(SessionContext loadedSession ApplicationConfig applicationConfig) throws FrameworkException { + if (hasOrganizationDiscoveryParameters(request)) { + return Optional.empty(); + } + boolean sharedAppLoginSession = (loadedSessionContext.getAuthenticatedSharedAppOrgId() != null) && (MapUtils.isNotEmpty(loadedSessionContext.getAuthenticatedOrgData())); String accessingOrgId = PrivilegedCarbonContext.getThreadLocalCarbonContext() @@ -1553,10 +1764,17 @@ private void populateContextWithPreviousSession(HttpServletRequest request, Auth } } } + String primaryApplicationTenantDomain = context.getTenantDomain(); + if (context.getOrganizationLoginData() != null && + context.getOrganizationLoginData().getPrimaryAppData() != null && + StringUtils.isNotBlank(context.getOrganizationLoginData().getPrimaryAppData().getTenantDomain())) { + primaryApplicationTenantDomain = + context.getOrganizationLoginData().getPrimaryAppData().getTenantDomain(); + } // This is done to reflect the changes done in SP to the sequence config. So, the requested claim // updates, authentication step updates will be reflected. refreshAppConfig(effectiveSequence, request.getParameter(FrameworkConstants.RequestParams.ISSUER), - context.getRequestType(), context.getTenantDomain()); + context.getRequestType(), primaryApplicationTenantDomain); Map authenticatedIdPsOfApp; if (orgId != null) { @@ -2005,6 +2223,7 @@ private void updateContextForOrganizationLogin(HttpServletRequest request, Authe int primaryAppId = applicationConfig.getApplicationID(); PrimaryAppData primaryAppData = new PrimaryAppData(); primaryAppData.setId(primaryAppId); + primaryAppData.setTenantDomain(applicationConfig.getServiceProvider().getTenantDomain()); context.getOrganizationLoginData().setPrimaryAppData(primaryAppData); // Setting the sequence config of the shared application to the context. diff --git a/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/main/java/org/wso2/carbon/identity/application/authentication/framework/model/PrimaryAppData.java b/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/main/java/org/wso2/carbon/identity/application/authentication/framework/model/PrimaryAppData.java index 4151a71531fe..120f6f7efc61 100644 --- a/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/main/java/org/wso2/carbon/identity/application/authentication/framework/model/PrimaryAppData.java +++ b/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/main/java/org/wso2/carbon/identity/application/authentication/framework/model/PrimaryAppData.java @@ -28,6 +28,7 @@ public class PrimaryAppData implements Serializable { private static final long serialVersionUID = -3925617004643909936L; private int id; + private String tenantDomain; public int getId() { @@ -38,4 +39,14 @@ public void setId(int id) { this.id = id; } + + public String getTenantDomain() { + + return tenantDomain; + } + + public void setTenantDomain(String tenantDomain) { + + this.tenantDomain = tenantDomain; + } } diff --git a/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/main/java/org/wso2/carbon/identity/application/authentication/framework/util/FrameworkUtils.java b/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/main/java/org/wso2/carbon/identity/application/authentication/framework/util/FrameworkUtils.java index 06bf59759b68..52a7eede2248 100644 --- a/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/main/java/org/wso2/carbon/identity/application/authentication/framework/util/FrameworkUtils.java +++ b/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/main/java/org/wso2/carbon/identity/application/authentication/framework/util/FrameworkUtils.java @@ -1324,6 +1324,46 @@ public static void addSessionContextToCache(String key, SessionContext sessionCo String loginTenantDomain, String orgId) { SessionContextCacheKey cacheKey = new SessionContextCacheKey(key); + SessionContextCacheEntry cacheEntry = buildSessionContextCacheEntry(key, sessionContext, tenantDomain, orgId); + SessionContextCache.getInstance().addToCache(cacheKey, cacheEntry, loginTenantDomain); + } + + /** + * Adds the given session context to the session context cache only, without persisting it to the session data + * store. + * + * @param key Session context cache key. + * @param sessionContext Session context to be cached. + * @param tenantDomain Application tenant domain used to resolve session timeout configurations. + * @param loginTenantDomain Login tenant domain under which the cache entry is stored. + * @param orgId Organization id used to look up authenticated sequences from + * {@link SessionContext#getAuthenticatedOrgData()}. + */ + public static void addSessionContextToCacheWithoutPersisting(String key, SessionContext sessionContext, + String tenantDomain, String loginTenantDomain, + String orgId) { + + SessionContextCacheKey cacheKey = new SessionContextCacheKey(key); + SessionContextCacheEntry cacheEntry = buildSessionContextCacheEntry(key, sessionContext, tenantDomain, orgId); + SessionContextCache.getInstance().addToCacheWithoutPersisting(cacheKey, cacheEntry, loginTenantDomain); + } + + /** + * Builds a {@link SessionContextCacheEntry} for the given session context. When an {@code orgId} is provided and + * the session context holds an {@link AuthenticatedOrgData} entry for that organization, the authenticated + * sequences of that organization are used for cleanup (clearing user attributes and the authentication graph). + * Otherwise, the top-level authenticated sequences of the session context are used. + * + * @param key Session context cache key. + * @param sessionContext Session context to be cached. + * @param tenantDomain Application tenant domain used to resolve session timeout configurations. + * @param orgId Organization id used to look up authenticated sequences from + * {@link SessionContext#getAuthenticatedOrgData()}. + * @return The built session context cache entry. + */ + private static SessionContextCacheEntry buildSessionContextCacheEntry(String key, SessionContext sessionContext, + String tenantDomain, String orgId) { + SessionContextCacheEntry cacheEntry = new SessionContextCacheEntry(); cacheEntry.setContextIdentifier(key); @@ -1364,7 +1404,7 @@ public static void addSessionContextToCache(String key, SessionContext sessionCo cacheEntry.setContext(sessionContext); cacheEntry.setValidityPeriod(timeoutPeriod); - SessionContextCache.getInstance().addToCache(cacheKey, cacheEntry, loginTenantDomain); + return cacheEntry; } /** diff --git a/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/test/java/org/wso2/carbon/identity/application/authentication/framework/handler/request/impl/DefaultAuthenticationRequestHandlerTest.java b/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/test/java/org/wso2/carbon/identity/application/authentication/framework/handler/request/impl/DefaultAuthenticationRequestHandlerTest.java index 9525cf548987..7ca781994d51 100644 --- a/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/test/java/org/wso2/carbon/identity/application/authentication/framework/handler/request/impl/DefaultAuthenticationRequestHandlerTest.java +++ b/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/test/java/org/wso2/carbon/identity/application/authentication/framework/handler/request/impl/DefaultAuthenticationRequestHandlerTest.java @@ -28,12 +28,14 @@ import org.testng.annotations.Test; import org.wso2.carbon.identity.application.authentication.framework.cache.AuthenticationResultCacheEntry; import org.wso2.carbon.identity.application.authentication.framework.config.model.ApplicationConfig; +import org.wso2.carbon.identity.application.authentication.framework.config.model.AuthenticatorConfig; import org.wso2.carbon.identity.application.authentication.framework.config.model.SequenceConfig; import org.wso2.carbon.identity.application.authentication.framework.context.AuthenticationContext; import org.wso2.carbon.identity.application.authentication.framework.exception.FrameworkException; import org.wso2.carbon.identity.application.authentication.framework.exception.PostAuthenticationFailedException; import org.wso2.carbon.identity.application.authentication.framework.handler.sequence.impl.DefaultStepBasedSequenceHandler; import org.wso2.carbon.identity.application.authentication.framework.internal.FrameworkServiceDataHolder; +import org.wso2.carbon.identity.application.authentication.framework.model.AuthenticatedIdPData; import org.wso2.carbon.identity.application.authentication.framework.model.AuthenticatedUser; import org.wso2.carbon.identity.application.authentication.framework.model.AuthenticationResult; import org.wso2.carbon.identity.application.authentication.framework.model.CommonAuthResponseWrapper; @@ -51,7 +53,9 @@ import org.wso2.carbon.identity.common.testng.WithCarbonHome; import java.io.IOException; +import java.lang.reflect.Method; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; @@ -75,6 +79,7 @@ import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNotSame; import static org.testng.Assert.assertNull; import static org.testng.Assert.assertTrue; import static org.wso2.carbon.base.MultitenantConstants.SUPER_TENANT_DOMAIN_NAME; @@ -633,5 +638,90 @@ public void testConcludeFlowAllowsTenantMismatchForSharedUser() throws Exception + "when user is a shared user"); } } + + @SuppressWarnings("unchecked") + private Map invokeMergeAuthenticatedIdPs( + Map previous, + Map current) throws Exception { + + Method method = DefaultAuthenticationRequestHandler.class.getDeclaredMethod( + "mergeAuthenticatedIdPs", Map.class, Map.class); + method.setAccessible(true); + return (Map) method.invoke( + new DefaultAuthenticationRequestHandler(), previous, current); + } + + private AuthenticatedIdPData buildAuthenticatedIdPData(String idpName, String... authenticatorNames) { + + AuthenticatedIdPData authenticatedIdPData = new AuthenticatedIdPData(); + authenticatedIdPData.setIdpName(idpName); + authenticatedIdPData.setUser(new AuthenticatedUser()); + List authenticators = new ArrayList<>(); + for (String authenticatorName : authenticatorNames) { + authenticators.add(new AuthenticatorConfig(authenticatorName, true, null)); + } + authenticatedIdPData.setAuthenticators(authenticators); + return authenticatedIdPData; + } + + private List authenticatorNames(AuthenticatedIdPData authenticatedIdPData) { + + List names = new ArrayList<>(); + for (AuthenticatorConfig authenticatorConfig : authenticatedIdPData.getAuthenticators()) { + names.add(authenticatorConfig.getName()); + } + return names; + } + + @Test(description = "Merging disjoint authenticated IdP maps keeps all IdPs from both maps.") + public void testMergeAuthenticatedIdPsWithDisjointIdPs() throws Exception { + + Map previous = new HashMap<>(); + previous.put("LOCAL", buildAuthenticatedIdPData("LOCAL", "BasicAuthenticator")); + Map current = new HashMap<>(); + current.put("FederatedIdP", buildAuthenticatedIdPData("FederatedIdP", "OpenIDConnectAuthenticator")); + + Map merged = invokeMergeAuthenticatedIdPs(previous, current); + + assertEquals(merged.size(), 2); + assertTrue(merged.containsKey("LOCAL")); + assertTrue(merged.containsKey("FederatedIdP")); + assertNotNull(merged.get("LOCAL")); + assertNotSame(merged.get("LOCAL"), previous.get("LOCAL")); + } + + @Test(description = "Merging IdP maps that share an IdP appends only the authenticators not already present.") + public void testMergeAuthenticatedIdPsAppendsMissingAuthenticators() throws Exception { + + Map previous = new HashMap<>(); + previous.put("LOCAL", buildAuthenticatedIdPData("LOCAL", "BasicAuthenticator")); + Map current = new HashMap<>(); + // Same IdP with a duplicate authenticator and a new authenticator. + current.put("LOCAL", buildAuthenticatedIdPData("LOCAL", "BasicAuthenticator", "TOTPAuthenticator")); + + Map merged = invokeMergeAuthenticatedIdPs(previous, current); + + assertEquals(merged.size(), 1); + List mergedAuthenticators = authenticatorNames(merged.get("LOCAL")); + assertEquals(mergedAuthenticators.size(), 2, "Duplicate authenticator should not be added twice."); + assertTrue(mergedAuthenticators.contains("BasicAuthenticator")); + assertTrue(mergedAuthenticators.contains("TOTPAuthenticator")); + } + + @Test(description = "Merging handles null and empty authenticated IdP maps gracefully.") + public void testMergeAuthenticatedIdPsWithNullAndEmptyMaps() throws Exception { + + Map current = new HashMap<>(); + current.put("LOCAL", buildAuthenticatedIdPData("LOCAL", "BasicAuthenticator")); + + // Previous map is null. + Map merged = invokeMergeAuthenticatedIdPs(null, current); + assertEquals(merged.size(), 1); + assertTrue(merged.containsKey("LOCAL")); + + // Both maps empty/null. + Map emptyMerge = invokeMergeAuthenticatedIdPs(null, new HashMap<>()); + assertTrue(emptyMerge.isEmpty()); + } } diff --git a/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/test/java/org/wso2/carbon/identity/application/authentication/framework/handler/request/impl/DefaultRequestCoordinatorTest.java b/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/test/java/org/wso2/carbon/identity/application/authentication/framework/handler/request/impl/DefaultRequestCoordinatorTest.java index bae187323bfc..dd443fb743ae 100644 --- a/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/test/java/org/wso2/carbon/identity/application/authentication/framework/handler/request/impl/DefaultRequestCoordinatorTest.java +++ b/components/authentication-framework/org.wso2.carbon.identity.application.authentication.framework/src/test/java/org/wso2/carbon/identity/application/authentication/framework/handler/request/impl/DefaultRequestCoordinatorTest.java @@ -26,15 +26,21 @@ import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import org.wso2.carbon.context.PrivilegedCarbonContext; +import org.wso2.carbon.identity.application.authentication.framework.ApplicationAuthenticator; import org.wso2.carbon.identity.application.authentication.framework.config.ConfigurationFacade; import org.wso2.carbon.identity.application.authentication.framework.config.model.ApplicationConfig; +import org.wso2.carbon.identity.application.authentication.framework.config.model.AuthenticatorConfig; import org.wso2.carbon.identity.application.authentication.framework.config.model.SequenceConfig; +import org.wso2.carbon.identity.application.authentication.framework.config.model.StepConfig; import org.wso2.carbon.identity.application.authentication.framework.config.model.graph.AuthGraphNode; import org.wso2.carbon.identity.application.authentication.framework.config.model.graph.ShowPromptNode; import org.wso2.carbon.identity.application.authentication.framework.context.AuthenticationContext; import org.wso2.carbon.identity.application.authentication.framework.context.SessionContext; import org.wso2.carbon.identity.application.authentication.framework.exception.CookieValidationFailedException; import org.wso2.carbon.identity.application.authentication.framework.exception.FrameworkException; +import org.wso2.carbon.identity.application.authentication.framework.internal.FrameworkServiceDataHolder; +import org.wso2.carbon.identity.application.authentication.framework.model.AuthenticatedIdPData; +import org.wso2.carbon.identity.application.authentication.framework.model.AuthenticatedOrgData; import org.wso2.carbon.identity.application.authentication.framework.model.AuthenticatedUser; import org.wso2.carbon.identity.application.authentication.framework.model.CommonAuthRequestWrapper; import org.wso2.carbon.identity.application.authentication.framework.model.CommonAuthResponseWrapper; @@ -55,6 +61,9 @@ import org.wso2.carbon.identity.core.util.IdentityTenantUtil; import org.wso2.carbon.identity.core.util.IdentityUtil; import org.wso2.carbon.identity.event.services.IdentityEventService; +import org.wso2.carbon.identity.organization.management.organization.user.sharing.OrganizationUserSharingService; +import org.wso2.carbon.identity.organization.management.organization.user.sharing.models.UserAssociation; +import org.wso2.carbon.identity.organization.management.service.exception.OrganizationManagementException; import org.wso2.carbon.identity.testutil.IdentityBaseTest; import java.io.IOException; @@ -62,7 +71,10 @@ import java.lang.reflect.Method; import java.net.URISyntaxException; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import javax.servlet.http.Cookie; @@ -84,6 +96,8 @@ import static org.mockito.Mockito.when; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNotSame; import static org.testng.Assert.assertNull; import static org.testng.Assert.assertTrue; import static org.wso2.carbon.identity.application.authentication.framework.util.FrameworkConstants.AUTHENTICATOR; @@ -988,4 +1002,384 @@ public void testUpdateServiceProviderUuidInOutboundQueryParams(String initialQue assertEquals(context.getQueryParams(), expectedQueryParams); } } + + @DataProvider(name = "orgDiscoveryParamProvider") + public Object[][] provideOrgDiscoveryParams() { + + return new Object[][]{ + // orgId, orgHandle, org, login_hint, expected. + {null, null, null, null, false}, + {"org-id-123", null, null, null, true}, + {null, "org.example.com", null, null, true}, + {null, null, "exampleOrg", null, true}, + {null, null, null, "user@example.com", true}, + {"", "", "", "", false}, + }; + } + + @Test(dataProvider = "orgDiscoveryParamProvider") + public void testHasOrganizationDiscoveryParameters(String orgId, String orgHandle, String org, String loginHint, + boolean expected) throws Exception { + + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getParameter(FrameworkConstants.OrgDiscoveryInputParameters.ORG_ID)).thenReturn(orgId); + when(request.getParameter(FrameworkConstants.OrgDiscoveryInputParameters.ORG_HANDLE)).thenReturn(orgHandle); + when(request.getParameter(FrameworkConstants.OrgDiscoveryInputParameters.ORG_NAME)).thenReturn(org); + when(request.getParameter(FrameworkConstants.OrgDiscoveryInputParameters.LOGIN_HINT)).thenReturn(loginHint); + + Method method = DefaultRequestCoordinator.class.getDeclaredMethod( + "hasOrganizationDiscoveryParameters", HttpServletRequest.class); + method.setAccessible(true); + boolean result = (boolean) method.invoke(requestCoordinator, request); + + assertEquals(result, expected); + } + + private boolean invokeIsAuthenticatedUserSharedToAccessingOrg(SessionContext sessionContext, String accessingOrgId) + throws Exception { + + Method method = DefaultRequestCoordinator.class.getDeclaredMethod( + "isAuthenticatedUserSharedToAccessingOrg", SessionContext.class, String.class); + method.setAccessible(true); + return (boolean) method.invoke(requestCoordinator, sessionContext, accessingOrgId); + } + + /** + * Builds a session context whose first authenticated organization data holds a sequence config carrying the + * given authenticated user. + */ + private SessionContext buildSessionContextWithAuthenticatedUser(AuthenticatedUser authenticatedUser) { + + SequenceConfig sequenceConfig = new SequenceConfig(); + sequenceConfig.setAuthenticatedUser(authenticatedUser); + AuthenticatedOrgData orgData = new AuthenticatedOrgData(); + orgData.getAuthenticatedSequences().put("app", sequenceConfig); + SessionContext sessionContext = new SessionContext(); + Map authenticatedOrgData = new HashMap<>(); + authenticatedOrgData.put("source-org-id", orgData); + sessionContext.setAuthenticatedOrgData(authenticatedOrgData); + return sessionContext; + } + + @Test(description = "The shared-user check returns false when the accessing organization ID is blank.") + public void testIsAuthenticatedUserSharedToAccessingOrgWithBlankOrgId() throws Exception { + + SessionContext sessionContext = buildSessionContextWithAuthenticatedUser(new AuthenticatedUser()); + assertFalse(invokeIsAuthenticatedUserSharedToAccessingOrg(sessionContext, " ")); + } + + @Test(description = "The shared-user check returns false when no authenticated user is found in the session.") + public void testIsAuthenticatedUserSharedToAccessingOrgWithoutAuthenticatedUser() throws Exception { + + SessionContext sessionContext = new SessionContext(); + sessionContext.setAuthenticatedOrgData(new HashMap<>()); + assertFalse(invokeIsAuthenticatedUserSharedToAccessingOrg(sessionContext, "accessing-org-id")); + } + + @Test(description = "The shared-user check returns true when the user has an association in the accessing org.") + public void testIsAuthenticatedUserSharedToAccessingOrgWhenShared() throws Exception { + + AuthenticatedUser authenticatedUser = new AuthenticatedUser(); + authenticatedUser.setUserId("user-id-123"); + SessionContext sessionContext = buildSessionContextWithAuthenticatedUser(authenticatedUser); + + OrganizationUserSharingService sharingService = mock(OrganizationUserSharingService.class); + when(sharingService.getUserAssociationOfAssociatedUserByOrgId("user-id-123", "accessing-org-id")) + .thenReturn(mock(UserAssociation.class)); + FrameworkServiceDataHolder.getInstance().setOrganizationUserSharingService(sharingService); + try { + assertTrue(invokeIsAuthenticatedUserSharedToAccessingOrg(sessionContext, "accessing-org-id")); + } finally { + FrameworkServiceDataHolder.getInstance().setOrganizationUserSharingService(null); + } + } + + @Test(description = "The shared-user check returns false when the user has no association in the accessing org.") + public void testIsAuthenticatedUserSharedToAccessingOrgWhenNotShared() throws Exception { + + AuthenticatedUser authenticatedUser = new AuthenticatedUser(); + authenticatedUser.setUserId("user-id-123"); + SessionContext sessionContext = buildSessionContextWithAuthenticatedUser(authenticatedUser); + + OrganizationUserSharingService sharingService = mock(OrganizationUserSharingService.class); + when(sharingService.getUserAssociationOfAssociatedUserByOrgId("user-id-123", "accessing-org-id")) + .thenReturn(null); + FrameworkServiceDataHolder.getInstance().setOrganizationUserSharingService(sharingService); + try { + assertFalse(invokeIsAuthenticatedUserSharedToAccessingOrg(sessionContext, "accessing-org-id")); + } finally { + FrameworkServiceDataHolder.getInstance().setOrganizationUserSharingService(null); + } + } + + @Test(description = "The shared-user check returns false and swallows OrganizationManagementException.") + public void testIsAuthenticatedUserSharedToAccessingOrgOnException() throws Exception { + + AuthenticatedUser authenticatedUser = new AuthenticatedUser(); + authenticatedUser.setUserId("user-id-123"); + SessionContext sessionContext = buildSessionContextWithAuthenticatedUser(authenticatedUser); + + OrganizationUserSharingService sharingService = mock(OrganizationUserSharingService.class); + when(sharingService.getUserAssociationOfAssociatedUserByOrgId(anyString(), anyString())) + .thenThrow(new OrganizationManagementException("error")); + FrameworkServiceDataHolder.getInstance().setOrganizationUserSharingService(sharingService); + try { + assertFalse(invokeIsAuthenticatedUserSharedToAccessingOrg(sessionContext, "accessing-org-id")); + } finally { + FrameworkServiceDataHolder.getInstance().setOrganizationUserSharingService(null); + } + } + + @Test(description = "Populating context with previous org sessions is a no-op when the session holds no " + + "authenticated organization data.") + public void testPopulateContextWithNoAuthenticatedOrgData() throws Exception { + + AuthenticationContext context = new AuthenticationContext(); + SessionContext sessionContext = new SessionContext(); + sessionContext.setAuthenticatedOrgData(new HashMap<>()); + + invokePopulateContext("accessing-org-id", context, new SequenceConfig(), sessionContext); + + assertFalse(context.isPreviousSessionFound()); + assertTrue(context.getPreviousAuthenticatedIdPs().isEmpty()); + } + + @Test(description = "Populating context with previous org sessions is a no-op when the user is not shared to the " + + "accessing organization.") + public void testPopulateContextWhenUserNotSharedToAccessingOrg() throws Exception { + + AuthenticatedUser authenticatedUser = new AuthenticatedUser(); + authenticatedUser.setUserId("user-id-123"); + SessionContext sessionContext = buildSessionContextWithAuthenticatedUser(authenticatedUser); + + OrganizationUserSharingService sharingService = mock(OrganizationUserSharingService.class); + when(sharingService.getUserAssociationOfAssociatedUserByOrgId(anyString(), anyString())).thenReturn(null); + FrameworkServiceDataHolder.getInstance().setOrganizationUserSharingService(sharingService); + + AuthenticationContext context = new AuthenticationContext(); + try { + invokePopulateContext("accessing-org-id", context, new SequenceConfig(), sessionContext); + assertFalse(context.isPreviousSessionFound()); + } finally { + FrameworkServiceDataHolder.getInstance().setOrganizationUserSharingService(null); + } + } + + @Test(description = "Populating context marks the matching step as authenticated and carries over the " + + "authenticated IdP data when the shared user has a previous organization session.") + public void testPopulateContextCarriesOverPreviousOrganizationSession() throws Exception { + + // Step requiring the federated IdP. + AuthenticatorConfig stepAuthenticator = new AuthenticatorConfig("OIDCAuthenticator", true, null); + stepAuthenticator.setIdPNames(Arrays.asList("FederatedIdP")); + StepConfig stepConfig = new StepConfig(); + stepConfig.setOrder(1); + stepConfig.setAuthenticatorList(Arrays.asList(stepAuthenticator)); + SequenceConfig effectiveSequence = new SequenceConfig(); + Map stepMap = new HashMap<>(); + stepMap.put(1, stepConfig); + effectiveSequence.setStepMap(stepMap); + + // Authenticated user shared to the accessing org. + AuthenticatedUser authenticatedUser = new AuthenticatedUser(); + authenticatedUser.setUserId("user-id-123"); + + // Authenticated IdP data already satisfied in another organization. + AuthenticatedIdPData authenticatedIdPData = new AuthenticatedIdPData(); + authenticatedIdPData.setIdpName("FederatedIdP"); + authenticatedIdPData.setUser(authenticatedUser); + authenticatedIdPData.addAuthenticator(new AuthenticatorConfig("OIDCAuthenticator", true, null)); + + SequenceConfig sourceSequenceConfig = new SequenceConfig(); + sourceSequenceConfig.setAuthenticatedUser(authenticatedUser); + AuthenticatedOrgData orgData = new AuthenticatedOrgData(); + orgData.getAuthenticatedSequences().put("app", sourceSequenceConfig); + orgData.getAuthenticatedIdPs().put("FederatedIdP", authenticatedIdPData); + + SessionContext sessionContext = new SessionContext(); + Map authenticatedOrgData = new HashMap<>(); + authenticatedOrgData.put("source-org-id", orgData); + sessionContext.setAuthenticatedOrgData(authenticatedOrgData); + + OrganizationUserSharingService sharingService = mock(OrganizationUserSharingService.class); + when(sharingService.getUserAssociationOfAssociatedUserByOrgId("user-id-123", "accessing-org-id")) + .thenReturn(mock(UserAssociation.class)); + FrameworkServiceDataHolder.getInstance().setOrganizationUserSharingService(sharingService); + + AuthenticationContext context = new AuthenticationContext(); + try { + invokePopulateContext("accessing-org-id", context, effectiveSequence, sessionContext); + + assertTrue(context.isPreviousSessionFound()); + Map previousAuthenticatedIdPs = context.getPreviousAuthenticatedIdPs(); + assertNotNull(previousAuthenticatedIdPs); + assertTrue(previousAuthenticatedIdPs.containsKey("FederatedIdP")); + + // The matching step should be marked as authenticated with the carried over authenticator. + assertEquals(stepConfig.getAuthenticatedIdP(), "FederatedIdP"); + assertNotNull(stepConfig.getAuthenticatedAutenticator()); + assertEquals(stepConfig.getAuthenticatedAutenticator().getName(), "OIDCAuthenticator"); + } finally { + FrameworkServiceDataHolder.getInstance().setOrganizationUserSharingService(null); + } + } + + @Test(description = "Populating context switches the accessing organization of the carried over authenticated " + + "user (and its IdP data) to the current accessing organization without mutating the cached entry.") + public void testPopulateContextSwitchesAccessingOrganizationForSharedUser() throws Exception { + + // Step requiring the federated IdP. + AuthenticatorConfig stepAuthenticator = new AuthenticatorConfig("OIDCAuthenticator", true, null); + stepAuthenticator.setIdPNames(List.of("FederatedIdP")); + StepConfig stepConfig = new StepConfig(); + stepConfig.setOrder(1); + stepConfig.setAuthenticatorList(List.of(stepAuthenticator)); + SequenceConfig effectiveSequence = new SequenceConfig(); + Map stepMap = new HashMap<>(); + stepMap.put(1, stepConfig); + effectiveSequence.setStepMap(stepMap); + + // Authenticated user that was accessing another (source) organization. + AuthenticatedUser authenticatedUser = new AuthenticatedUser(); + authenticatedUser.setUserId("user-id-123"); + authenticatedUser.setAccessingOrganization("source-org-id"); + + AuthenticatedIdPData authenticatedIdPData = new AuthenticatedIdPData(); + authenticatedIdPData.setIdpName("FederatedIdP"); + authenticatedIdPData.setUser(authenticatedUser); + authenticatedIdPData.addAuthenticator(new AuthenticatorConfig("OIDCAuthenticator", true, null)); + + SequenceConfig sourceSequenceConfig = new SequenceConfig(); + sourceSequenceConfig.setAuthenticatedUser(authenticatedUser); + AuthenticatedOrgData orgData = new AuthenticatedOrgData(); + orgData.getAuthenticatedSequences().put("app", sourceSequenceConfig); + orgData.getAuthenticatedIdPs().put("FederatedIdP", authenticatedIdPData); + + SessionContext sessionContext = new SessionContext(); + Map authenticatedOrgData = new HashMap<>(); + authenticatedOrgData.put("source-org-id", orgData); + sessionContext.setAuthenticatedOrgData(authenticatedOrgData); + + OrganizationUserSharingService sharingService = mock(OrganizationUserSharingService.class); + when(sharingService.getUserAssociationOfAssociatedUserByOrgId("user-id-123", "accessing-org-id")) + .thenReturn(mock(UserAssociation.class)); + FrameworkServiceDataHolder.getInstance().setOrganizationUserSharingService(sharingService); + + AuthenticationContext context = new AuthenticationContext(); + try { + invokePopulateContext("accessing-org-id", context, effectiveSequence, sessionContext); + + // The step's authenticated user should now point to the current accessing organization. + assertNotNull(stepConfig.getAuthenticatedUser()); + assertEquals(stepConfig.getAuthenticatedUser().getAccessingOrganization(), "accessing-org-id"); + + // The carried over IdP data should be a clone (not the cached instance) whose user's accessing + // organization was switched as well. + AuthenticatedIdPData carriedOverIdPData = context.getPreviousAuthenticatedIdPs().get("FederatedIdP"); + assertNotNull(carriedOverIdPData); + assertNotSame(carriedOverIdPData, authenticatedIdPData); + assertEquals(carriedOverIdPData.getUser().getAccessingOrganization(), "accessing-org-id"); + + // The cached entry should remain untouched. + assertEquals(authenticatedUser.getAccessingOrganization(), "source-org-id"); + } finally { + FrameworkServiceDataHolder.getInstance().setOrganizationUserSharingService(null); + } + } + + @Test(description = "Populating context merges authenticators for the same IdP that the shared user satisfied " + + "across different organizations, appending only the authenticators not already carried over.") + public void testPopulateContextMergesAuthenticatorsAcrossOrganizations() throws Exception { + + // Two LOCAL steps, each requiring a distinct local authenticator. + ApplicationAuthenticator basicAppAuthenticator = mock(ApplicationAuthenticator.class); + when(basicAppAuthenticator.getAuthMechanism()).thenReturn("basic"); + AuthenticatorConfig step1Authenticator = new AuthenticatorConfig("BasicAuthenticator", true, null); + step1Authenticator.setIdPNames(List.of(FrameworkConstants.LOCAL)); + step1Authenticator.setApplicationAuthenticator(basicAppAuthenticator); + StepConfig step1 = new StepConfig(); + step1.setOrder(1); + step1.setAuthenticatorList(List.of(step1Authenticator)); + + ApplicationAuthenticator totpAppAuthenticator = mock(ApplicationAuthenticator.class); + when(totpAppAuthenticator.getAuthMechanism()).thenReturn("totp"); + AuthenticatorConfig step2Authenticator = new AuthenticatorConfig("TOTPAuthenticator", true, null); + step2Authenticator.setIdPNames(List.of(FrameworkConstants.LOCAL)); + step2Authenticator.setApplicationAuthenticator(totpAppAuthenticator); + StepConfig step2 = new StepConfig(); + step2.setOrder(2); + step2.setAuthenticatorList(List.of(step2Authenticator)); + + SequenceConfig effectiveSequence = new SequenceConfig(); + Map stepMap = new LinkedHashMap<>(); + stepMap.put(1, step1); + stepMap.put(2, step2); + effectiveSequence.setStepMap(stepMap); + + AuthenticatedUser authenticatedUser = new AuthenticatedUser(); + authenticatedUser.setUserId("user-id-123"); + + // Org A satisfied the BasicAuthenticator only. + AuthenticatedIdPData orgALocalIdPData = new AuthenticatedIdPData(); + orgALocalIdPData.setIdpName(FrameworkConstants.LOCAL); + orgALocalIdPData.setUser(authenticatedUser); + orgALocalIdPData.addAuthenticator(new AuthenticatorConfig("BasicAuthenticator", true, null)); + SequenceConfig orgASequenceConfig = new SequenceConfig(); + orgASequenceConfig.setAuthenticatedUser(authenticatedUser); + AuthenticatedOrgData orgAData = new AuthenticatedOrgData(); + orgAData.getAuthenticatedSequences().put("app", orgASequenceConfig); + orgAData.getAuthenticatedIdPs().put(FrameworkConstants.LOCAL, orgALocalIdPData); + + // Org B satisfied the TOTPAuthenticator only. + AuthenticatedIdPData orgBLocalIdPData = new AuthenticatedIdPData(); + orgBLocalIdPData.setIdpName(FrameworkConstants.LOCAL); + orgBLocalIdPData.setUser(authenticatedUser); + orgBLocalIdPData.addAuthenticator(new AuthenticatorConfig("TOTPAuthenticator", true, null)); + AuthenticatedOrgData orgBData = new AuthenticatedOrgData(); + orgBData.getAuthenticatedIdPs().put(FrameworkConstants.LOCAL, orgBLocalIdPData); + + // Preserve iteration order so org A wins step 1 and org B wins step 2. + SessionContext sessionContext = new SessionContext(); + Map authenticatedOrgData = new LinkedHashMap<>(); + authenticatedOrgData.put("org-a-id", orgAData); + authenticatedOrgData.put("org-b-id", orgBData); + sessionContext.setAuthenticatedOrgData(authenticatedOrgData); + + OrganizationUserSharingService sharingService = mock(OrganizationUserSharingService.class); + when(sharingService.getUserAssociationOfAssociatedUserByOrgId("user-id-123", "accessing-org-id")) + .thenReturn(mock(UserAssociation.class)); + FrameworkServiceDataHolder.getInstance().setOrganizationUserSharingService(sharingService); + + AuthenticationContext context = new AuthenticationContext(); + try { + invokePopulateContext("accessing-org-id", context, effectiveSequence, sessionContext); + + assertTrue(context.isPreviousSessionFound()); + AuthenticatedIdPData mergedLocalIdPData = + context.getPreviousAuthenticatedIdPs().get(FrameworkConstants.LOCAL); + assertNotNull(mergedLocalIdPData); + + // The authenticator satisfied in org B should have been appended to org A's carried over entry. + List mergedAuthenticatorNames = new ArrayList<>(); + for (AuthenticatorConfig authenticatorConfig : mergedLocalIdPData.getAuthenticators()) { + mergedAuthenticatorNames.add(authenticatorConfig.getName()); + } + assertEquals(mergedAuthenticatorNames.size(), 2); + assertTrue(mergedAuthenticatorNames.contains("BasicAuthenticator")); + assertTrue(mergedAuthenticatorNames.contains("TOTPAuthenticator")); + } finally { + FrameworkServiceDataHolder.getInstance().setOrganizationUserSharingService(null); + } + } + + private void invokePopulateContext(String accessingOrgId, AuthenticationContext context, + SequenceConfig effectiveSequence, SessionContext loadedSessionContext) + throws Exception { + + Method method = DefaultRequestCoordinator.class.getDeclaredMethod( + "populateContextWithPreviousAuthenticatedOrganizationSessions", String.class, + AuthenticationContext.class, SequenceConfig.class, SessionContext.class); + method.setAccessible(true); + method.invoke(requestCoordinator, accessingOrgId, context, effectiveSequence, loadedSessionContext); + } }