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 <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2025-07-10 15:38:47 -03:00 committed by GitHub
parent 88069cd5fb
commit 919554e6fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 250 additions and 37 deletions

View file

@ -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<OrganizationModel> organizations = provider.getByMember(user);
if (organizations.count() > 1) {

View file

@ -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<OrganizationModel> 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<OrganizationModel> 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<OrganizationModel> 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<OrganizationDomainModel> domains = organization.getDomains();
Stream<String> 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<OrganizationDomainModel> 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<OrganizationModel> resolveUserOrganization(List<OrganizationModel> 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);
}
}

View file

@ -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<UserRepresentation> reps = users.searchByEmail(userEmail, true);
Assert.assertFalse(reps.isEmpty());
Assert.assertEquals(1, reps.size());

View file

@ -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());