From 919554e6fc67f7bb3f6e546ce20401c0a04fe2b8 Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Thu, 10 Jul 2025 15:38:47 -0300 Subject: [PATCH] Resolve organization when scope is requested and the user is a member or the email domain matches the organization Closes #39864 Signed-off-by: Pedro Igor --- .../browser/OrganizationAuthenticator.java | 9 +- .../organization/utils/Organizations.java | 89 ++++++--- .../admin/AbstractOrganizationTest.java | 6 +- .../AbstractBrokerSelfRegistrationTest.java | 183 ++++++++++++++++++ 4 files changed, 250 insertions(+), 37 deletions(-) diff --git a/services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAuthenticator.java b/services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAuthenticator.java index 8181cee060c..b791480fe46 100644 --- a/services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAuthenticator.java +++ b/services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAuthenticator.java @@ -215,19 +215,18 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator { } private boolean shouldUserSelectOrganization(AuthenticationFlowContext context, UserModel user) { - OrganizationProvider provider = getOrganizationProvider(); - AuthenticationSessionModel authSession = context.getAuthenticationSession(); - OrganizationScope scope = OrganizationScope.valueOfScope(session); - - if (!OrganizationScope.ANY.equals(scope) || user == null) { + if (user == null || !OrganizationScope.ANY.equals(OrganizationScope.valueOfScope(session))) { return false; } + AuthenticationSessionModel authSession = context.getAuthenticationSession(); + if (authSession.getClientNote(OrganizationModel.ORGANIZATION_ATTRIBUTE) != null) { // organization already selected return false; } + OrganizationProvider provider = getOrganizationProvider(); Stream organizations = provider.getByMember(user); if (organizations.count() > 1) { diff --git a/services/src/main/java/org/keycloak/organization/utils/Organizations.java b/services/src/main/java/org/keycloak/organization/utils/Organizations.java index fa088f1c237..be0d0aa5300 100644 --- a/services/src/main/java/org/keycloak/organization/utils/Organizations.java +++ b/services/src/main/java/org/keycloak/organization/utils/Organizations.java @@ -17,6 +17,7 @@ package org.keycloak.organization.utils; +import static java.util.Optional.of; import static java.util.Optional.ofNullable; import jakarta.ws.rs.core.MultivaluedMap; @@ -29,7 +30,6 @@ import java.util.Optional; import java.util.function.Consumer; import java.util.stream.Stream; -import org.keycloak.OAuth2Constants; import org.keycloak.TokenVerifier; import org.keycloak.authentication.actiontoken.inviteorg.InviteOrgActionToken; import org.keycloak.common.Profile; @@ -41,6 +41,7 @@ import org.keycloak.models.FederatedIdentityModel; import org.keycloak.models.GroupModel; import org.keycloak.models.GroupModel.Type; import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeycloakContext; import org.keycloak.models.KeycloakSession; import org.keycloak.models.OrganizationDomainModel; import org.keycloak.models.OrganizationModel; @@ -188,15 +189,18 @@ public class Organizations { } public static OrganizationModel resolveOrganization(KeycloakSession session, UserModel user, String domain) { - if (!session.getContext().getRealm().isOrganizationsEnabled()) { + KeycloakContext context = session.getContext(); + RealmModel realm = context.getRealm(); + + if (!realm.isOrganizationsEnabled()) { return null; } - Optional organization = Optional.ofNullable(session.getContext().getOrganization()); + OrganizationModel current = context.getOrganization(); - if (organization.isPresent()) { + if (current != null) { // resolved from current keycloak session - return organization.get(); + return current; } OrganizationProvider provider = getProvider(session); @@ -205,51 +209,44 @@ public class Organizations { return null; } - AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession(); + AuthenticationSessionModel authSession = context.getAuthenticationSession(); if (authSession != null) { OrganizationScope scope = OrganizationScope.valueOfScope(session); - List organizations = ofNullable(authSession.getAuthNote(OrganizationModel.ORGANIZATION_ATTRIBUTE)) .map(provider::getById) .map(List::of) .orElseGet(() -> scope == null ? List.of() : scope.resolveOrganizations(user, session).toList()); if (organizations.size() == 1) { - // single organization mapped from authentication session - OrganizationModel resolved = organizations.get(0); + OrganizationModel organization = organizations.get(0); if (user == null) { - return resolved; + return organization; } // make sure the user still maps to the organization from the authentication session - if (matchesOrganization(resolved, user)) { - return resolved; + if (organization.isMember(user) || matchesOrganizationDomain(organization, user, domain)) { + return organization; } return null; } else if (scope != null && user != null) { - // organization scope requested but no user and no single organization mapped from the scope - return null; + return resolveUserOrganization(organizations, user, domain).orElse(null); } } - organization = ofNullable(user).stream().flatMap(provider::getByMember) + List organizations = ofNullable(user).stream() + .flatMap(provider::getByMember) .filter(OrganizationModel::isEnabled) - .findAny(); + .toList(); - if (organization.isPresent()) { - return organization.get(); + if (organizations.size() == 1) { + return organizations.get(0); } - if (user != null && domain == null) { - domain = getEmailDomain(user); - } - - return ofNullable(domain) - .map(provider::getByDomainName) - .orElse(null); + return resolveUserOrganization(organizations, user, domain) + .orElseGet(() -> resolveOrganizationByDomain(user, domain, provider)); } public static OrganizationProvider getProvider(KeycloakSession session) { @@ -282,15 +279,45 @@ public class Organizations { (!organizationProvider.isEnabled() && org.isManaged(delegate))); } - private static boolean matchesOrganization(OrganizationModel organization, UserModel user) { - if (organization == null || user == null) { + private static boolean matchesOrganizationDomain(OrganizationModel organization, UserModel user, String domain) { + if (organization == null) { return false; } - String emailDomain = Optional.ofNullable(getEmailDomain(user)).orElse(""); - Stream domains = organization.getDomains(); - Stream domainNames = domains.map(OrganizationDomainModel::getName); + String emailDomain = Optional.ofNullable(domain).orElseGet(() -> getEmailDomain(user)); - return organization.isMember(user) || domainNames.anyMatch(emailDomain::equals); + if (emailDomain == null) { + return false; + } + + Stream domains = organization.getDomains(); + + return domains.map(OrganizationDomainModel::getName).anyMatch(emailDomain::equals); + } + + private static OrganizationModel resolveOrganizationByDomain(UserModel user, String domain, OrganizationProvider provider) { + if (user != null && domain == null) { + domain = getEmailDomain(user); + } + + return ofNullable(domain) + .map(provider::getByDomainName) + .orElse(null); + } + + private static Optional resolveUserOrganization(List organizations, UserModel user, String domain) { + OrganizationModel orgByDomain = null; + + for (OrganizationModel o : organizations) { + if (o.isManaged(user)) { + return of(o); + } + + if (matchesOrganizationDomain(o, user, domain)) { + orgByDomain = o; + } + } + + return ofNullable(orgByDomain); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractOrganizationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractOrganizationTest.java index 8dfe01df66c..35f4c63989f 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractOrganizationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/AbstractOrganizationTest.java @@ -256,7 +256,11 @@ public abstract class AbstractOrganizationTest extends AbstractAdminTest { } protected UserRepresentation getUserRepresentation(String userEmail) { - UsersResource users = adminClient.realm(bc.consumerRealmName()).users(); + return getUserRepresentation(bc.consumerRealmName(), userEmail); + } + + protected UserRepresentation getUserRepresentation(String realm, String userEmail) { + UsersResource users = adminClient.realm(realm).users(); List reps = users.searchByEmail(userEmail, true); Assert.assertFalse(reps.isEmpty()); Assert.assertEquals(1, reps.size()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/broker/AbstractBrokerSelfRegistrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/broker/AbstractBrokerSelfRegistrationTest.java index 706ee50d879..4a87b1b0182 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/broker/AbstractBrokerSelfRegistrationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/broker/AbstractBrokerSelfRegistrationTest.java @@ -301,6 +301,189 @@ public abstract class AbstractBrokerSelfRegistrationTest extends AbstractOrganiz assertIsMember(bc.getUserEmail(), organization); } + @Test + public void testRedirectManagedMemberOfMultipleOrganizations() { + OrganizationResource orgA = testRealm().organizations().get(createOrganization("org-a").getId()); + OrganizationResource orgB = testRealm().organizations().get(createOrganization("org-b").getId()); + String email = bc.getUserLogin() + "@" + orgA.toRepresentation().getDomains().iterator().next().getName(); + UserRepresentation account = UserBuilder.create() + .username(email) + .email(email) + .password(bc.getUserPassword()) + .enabled(true) + .build(); + UsersResource users = realmsResouce().realm(bc.providerRealmName()).users(); + try (Response response = users.create(account)) { + account.setId(ApiUtil.getCreatedId(response)); + } + UserRepresentation finalAccount = account; + getCleanup().addCleanup(() -> users.get(finalAccount.getId()).remove()); + // add the member for the first time + assertBrokerRegistration(orgA, email, email); + account = getUserRepresentation(account.getEmail()); + realmsResouce().realm(bc.consumerRealmName()).users().get(account.getId()).logout(); + realmsResouce().realm(bc.providerRealmName()).logoutAll(); + + orgB.members().addMember(account.getId()).close(); + openIdentityFirstLoginPage(email, true, null, false, false); + // login to the organization identity provider using e-mail and automatically redirects to the app as the account already exists + loginPage.login(email, bc.getUserPassword()); + appPage.assertCurrent(); + UserRepresentation finalAccount1 = account; + getCleanup().addCleanup(() -> realmsResouce().realm(bc.consumerRealmName()).users().get(finalAccount1.getId()).remove()); + + realmsResouce().realm(bc.consumerRealmName()).users().get(account.getId()).logout(); + realmsResouce().realm(bc.providerRealmName()).logoutAll(); + openIdentityFirstLoginPage(email, true, null, false, false); + // login to the organization identity provider user username and automatically redirects to the app as the account already exists + loginPage.login(account.getUsername(), bc.getUserPassword()); + appPage.assertCurrent(); + } + + @Test + public void testRedirectManagedMemberOfMultipleOrganizationsAllOrganizationsScope() { + OrganizationResource orgA = testRealm().organizations().get(createOrganization("org-a").getId()); + OrganizationResource orgB = testRealm().organizations().get(createOrganization("org-b").getId()); + String email = bc.getUserLogin() + "@" + orgA.toRepresentation().getDomains().iterator().next().getName(); + UserRepresentation account = UserBuilder.create() + .username(email) + .email(email) + .password(bc.getUserPassword()) + .enabled(true) + .build(); + UsersResource users = realmsResouce().realm(bc.providerRealmName()).users(); + try (Response response = users.create(account)) { + account.setId(ApiUtil.getCreatedId(response)); + } + UserRepresentation finalAccount = account; + getCleanup().addCleanup(() -> users.get(finalAccount.getId()).remove()); + // add the member for the first time + assertBrokerRegistration(orgA, email, email); + account = getUserRepresentation(account.getEmail()); + UserRepresentation finalAccount1 = account; + getCleanup().addCleanup(() -> realmsResouce().realm(bc.consumerRealmName()).users().get(finalAccount1.getId()).remove()); + realmsResouce().realm(bc.consumerRealmName()).users().get(account.getId()).logout(); + realmsResouce().realm(bc.providerRealmName()).logoutAll(); + + orgB.members().addMember(account.getId()).close(); + oauth.scope("organization:*"); + openIdentityFirstLoginPage(email, true, null, false, false); + // login to the organization identity provider by username and automatically redirects to the app as the account already exists + loginPage.login(email, bc.getUserPassword()); + appPage.assertCurrent(); + } + + @Test + public void testRedirectManagedMemberUsingUnManagedMemberAllOrganizationsScope() { + OrganizationResource orgA = testRealm().organizations().get(createOrganization("org-a").getId()); + OrganizationResource orgB = testRealm().organizations().get(createOrganization("org-b").getId()); + String email = bc.getUserLogin() + "@" + orgA.toRepresentation().getDomains().iterator().next().getName(); + UserRepresentation account = UserBuilder.create() + .username(email) + .email(email) + .password(bc.getUserPassword()) + .enabled(true) + .build(); + UsersResource users = realmsResouce().realm(bc.providerRealmName()).users(); + try (Response response = users.create(account)) { + account.setId(ApiUtil.getCreatedId(response)); + } + UserRepresentation finalAccount = account; + getCleanup().addCleanup(() -> users.get(finalAccount.getId()).remove()); + // add the member for the first time + assertBrokerRegistration(orgA, email, email); + account = getUserRepresentation(account.getEmail()); + UserRepresentation finalAccount1 = account; + getCleanup().addCleanup(() -> realmsResouce().realm(bc.consumerRealmName()).users().get(finalAccount1.getId()).remove()); + realmsResouce().realm(bc.consumerRealmName()).users().get(account.getId()).logout(); + realmsResouce().realm(bc.providerRealmName()).logoutAll(); + + orgB.members().addMember(account.getId()).close(); + oauth.scope("organization:*"); + String orgBEmail = bc.getUserLogin() + "@" + orgB.toRepresentation().getDomains().iterator().next().getName(); + openIdentityFirstLoginPage(orgBEmail, true, null, false, false); + // login to the organization identity provider by username and as to review profile because the user is not yet linked with the idp + loginPage.login(email, bc.getUserPassword()); + updateAccountInformationPage.assertCurrent(); + // should enforce a email with the same domain as the organization + updateAccountInformationPage.updateAccountInformation(email, email, "f", "l"); + Assert.assertTrue(driver.getPageSource().contains("Email domain does not match any domain from the organization")); + + updateAccountInformationPage.updateAccountInformation(email, orgBEmail, "f", "l"); + // user is asked to link accounts + idpConfirmLinkPage.assertCurrent(); + } + + @Test + public void testRedirectUnManagedMemberAllOrganizationsScope() { + OrganizationResource orgA = testRealm().organizations().get(createOrganization("org-a").getId()); + OrganizationResource orgB = testRealm().organizations().get(createOrganization("org-b").getId()); + String orgAEmail = bc.getUserLogin() + "@" + orgA.toRepresentation().getDomains().iterator().next().getName(); + // create user without credential to force the redirect to the IdP + UserRepresentation account = UserBuilder.create() + .username(orgAEmail) + .email(orgAEmail) + .enabled(true) + .build(); + UsersResource users = realmsResouce().realm(bc.consumerRealmName()).users(); + try (Response response = users.create(account)) { + String id = ApiUtil.getCreatedId(response); + account.setId(id); + getCleanup().addCleanup(() -> users.get(id).remove()); + } + + // add the unmanaged member to both organizations + orgA.members().addMember(account.getId()).close(); + orgB.members().addMember(account.getId()).close(); + + oauth.scope("organization:*"); + // resolve both organizations and redirect the user automatically + openIdentityFirstLoginPage(orgAEmail, true, null, false, false); + assertTrue(driver.getPageSource().contains("Sign in to provider")); + openIdentityFirstLoginPage(bc.getUserLogin() + "@" + orgB.toRepresentation().getDomains().iterator().next().getName(), true, null, false, false); + assertTrue(driver.getPageSource().contains("Sign in to provider")); + + oauth.scope("organization"); + oauth.clientId("broker-app"); + loginPage.open(bc.consumerRealmName()); + loginPage.loginUsername(orgAEmail); + selectOrganizationPage.assertCurrent(); + } + + @Test + public void testRedirectBrokerManagedMemberUsingUsernameAllOrganizationsScope() { + OrganizationResource orgA = testRealm().organizations().get(createOrganization("org-a").getId()); + OrganizationResource orgB = testRealm().organizations().get(createOrganization("org-b").getId()); + String email = bc.getUserLogin() + "@" + orgA.toRepresentation().getDomains().iterator().next().getName(); + UserRepresentation account = UserBuilder.create() + .username(email) + .email(email) + .password(bc.getUserPassword()) + .enabled(true) + .build(); + UsersResource users = realmsResouce().realm(bc.providerRealmName()).users(); + try (Response response = users.create(account)) { + account.setId(ApiUtil.getCreatedId(response)); + } + UserRepresentation finalAccount = account; + getCleanup().addCleanup(() -> users.get(finalAccount.getId()).remove()); + // add the member for the first time + assertBrokerRegistration(orgA, bc.getUserLogin(), email); + account = getUserRepresentation(account.getEmail()); + UserRepresentation finalAccount1 = account; + getCleanup().addCleanup(() -> realmsResouce().realm(bc.consumerRealmName()).users().get(finalAccount1.getId()).remove()); + realmsResouce().realm(bc.consumerRealmName()).users().get(account.getId()).logout(); + realmsResouce().realm(bc.providerRealmName()).logoutAll(); + + orgB.members().addMember(account.getId()).close(); + oauth.scope("organization:*"); + // provide the username, the user is automatically redirected to home broker because he is a managed member + openIdentityFirstLoginPage(bc.getUserLogin(), true, null, false, false); + // login to the organization identity provider by username + loginPage.login(bc.getUserLogin(), bc.getUserPassword()); + appPage.assertCurrent(); + } + @Test public void testRedirectBrokerWhenUnmanagedMemberProfileEmailMatchesOrganization() { OrganizationResource organization = testRealm().organizations().get(createOrganization().getId());