FFFF A User belonging to multiple organization should be allowed to change… by vramik · Pull Request #48165 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
public interface OrganizationModel {

String ORGANIZATION_ATTRIBUTE = "kc.org";
String ORGANIZATION_SWITCHABLE_ATTRIBUTE = "kc.org.switchable";
String ORGANIZATION_NAME_ATTRIBUTE = "kc.org.name";
String ORGANIZATION_DOMAIN_ATTRIBUTE = "kc.org.domain";
String ALIAS = "alias";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,15 @@
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;

import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator;
import org.keycloak.authentication.authenticators.conditional.ConditionalAuthenticator;
import org.keycloak.authentication.authenticators.util.AuthenticatorUtils;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.Constants;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.UserModel;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.services.ServicesLogger;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.sessions.CommonClientSessionModel;
Expand Down Expand Up @@ -95,6 +98,22 @@ public Response processAction(String actionExecution) {
MultivaluedMap<String, String> inputData = processor.getRequest().getDecodedFormParameters();
String authExecId = inputData.getFirst(Constants.AUTHENTICATION_EXECUTION);

// User clicked on "switch organization" link
if (inputData.containsKey("switchOrganization")) {
Comment thread
pedroigor marked this conversation as resolved.
Resolved
logger.trace("User clicked on link 'Switch Organization'");
AuthenticationSessionModel authSession = processor.getAuthenticationSession();
// preserve the username so the user doesn't have to re-enter it after flow reset
String attemptedUsername = authSession.getAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME);
if (attemptedUsername != null) {
authSession.setClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM, attemptedUsername);
}
authSession.removeClientNote(OrganizationModel.ORGANIZATION_ATTRIBUTE);
// clear the cached organization from the session context so it is re-evaluated after flow reset
processor.getSession().getContext().setOrganization(null);
processor.resetFlow();
return processor.authenticate();
}

// User clicked on "try another way" link
if (inputData.containsKey("tryAnotherWay")) {
logger.trace("User clicked on link 'Try Another Way'");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,9 @@ protected void createCommonAttributes(Theme theme, Locale locale, Properties mes
attributes.put("url", new UrlBean(realm, theme, baseUri, this.actionUri));
attributes.put("requiredActionUrl", new RequiredActionUrlFormatterMethod(realm, baseUri));
attributes.put("auth", new AuthenticationContextBean(context, page));
if (authenticationSession != null && Boolean.parseBoolean(authenticationSession.getAuthNote(OrganizationModel.ORGANIZATION_SWITCHABLE_ATTRIBUTE))) {
attributes.put("switchOrganizationEnabled", true);
}
if (authenticationSession != null && Boolean.parseBoolean(authenticationSession.getAuthNote(AbstractUsernameFormAuthenticator.USERNAME_HIDDEN))) {
attributes.put(LoginFormsProvider.USERNAME_HIDDEN, Boolean.TRUE.toString());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ private void action(AuthenticationFlowContext context, String username) {
OrganizationModel organization = resolveOrganization(user, domain);

if (organization == null) {
// remember the username before the org selection challenge so it can be preserved
// when the user switches organizations (the switch handler reads ATTEMPTED_USERNAME)
if (user != null && username != null) {
context.getAuthenticationSession().setAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME, username);
}
if (shouldUserSelectOrganization(context, user)) {
return;
}
Expand Down Expand Up @@ -217,6 +222,11 @@ private OrganizationModel resolveOrganization(UserModel user, String domain) {
authSession.setClientNote(OrganizationModel.ORGANIZATION_ATTRIBUTE, organization.getId());
}

if (!alias.isEmpty()) {
// user explicitly selected an organization, allow switching later in the flow
authSession.setAuthNote(OrganizationModel.ORGANIZATION_SWITCHABLE_ATTRIBUTE, Boolean.TRUE.toString());
}

return organization;
}

Expand Down
8BD0
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ public abstract class LanguageComboboxAwarePage extends AbstractPage {
@FindBy(id = "try-another-way")
private WebElement tryAnotherWayLink;

@FindBy(id = "switch-organization")
private WebElement switchOrganizationLink;

@FindBy(id = "kc-attempted-username")
private WebElement attemptedUsernameLabel;

Expand Down Expand Up @@ -100,6 +103,19 @@ public void clickTryAnotherWayLink() {
UIUtils.clickLink(tryAnotherWayLink);
}

public void assertSwitchOrganizationLinkAvailability(boolean expectedAvailability) {
try {
driver.findElement(By.id("switch-organization"));
Assert.assertTrue(expectedAvailability);
} catch (NoSuchElementException nse) {
Assert.assertFalse(expectedAvailability);
}
}

public void clickSwitchOrganizationLink() {
UIUtils.clickLink(switchOrganizationLink);
}

public void assertAccountLinkAvailability(boolean expectedAvailability) {
try {
driver.findElement(By.id("account"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,33 +21,40 @@
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;

import jakarta.ws.rs.core.Response;

import org.keycloak.OAuth2Constants;
import org.keycloak.admin.client.resource.OrganizationResource;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel.RequiredAction;
import org.keycloak.models.utils.DefaultAuthenticationFlows;
import org.keycloak.organization.authentication.authenticators.browser.OrganizationAuthenticatorFactory;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.idm.OrganizationRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.admin.AdminApiUtil;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.broker.KcOidcBrokerConfiguration;
import org.keycloak.testsuite.organization.admin.AbstractOrganizationTest;
import org.keycloak.testsuite.runonserver.RunOnServer;
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
import org.keycloak.testsuite.util.FlowUtil;
import org.keycloak.testsuite.util.UserBuilder;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;

import org.hamcrest.Matcher;
import org.hamcrest.Matchers;
import org.junit.Test;

import static org.keycloak.testsuite.broker.BrokerTestTools.waitForPage;

import static org.hamcrest.CoreMatchers.hasItem;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.not;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.assertEquals;

Expand Down Expand Up @@ -400,6 +407,163 @@ public void testUsernameExposureWhenEnteringEmail() {
testRealm().users().get(memberId).remove();
}

@Test
@SuppressWarnings("unchecked")
public void testSwitchOrganizationDuringLogin() {
OrganizationRepresentation orgA = createOrganization();
OrganizationRepresentation orgB = createOrganization("org-b");
OrganizationResource orgAResource = testRealm().organizations().get(orgA.getId());
OrganizationResource orgBResource = testRealm().organizations().get(orgB.getId());
UserRepresentation member = addMember(orgAResource);
orgBResource.members().addMember(member.getId()).close();

// login with "organization" scope (ANY) to trigger org selection
oauth.client("broker-app", KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET);
oauth.scope("organization");
loginPage.open(bc.consumerRealmName());
loginPage.loginUsername(member.getEmail());

// org selection page should be shown
selectOrganizationPage.assertCurrent();
Assert.assertTrue(selectOrganizationPage.isOrganizationButtonPresent(orgA.getAlias()));
Assert.assertTrue(selectOrganizationPage.isOrganizationButtonPresent(orgB.getAlias()));

// select org A
selectOrganizationPage.selectOrganization(orgA.getAlias());

// should be on the password page now
loginPage.assertAttemptedUsernameAvailability(true);
10BC8 Assert.assertTrue(loginPage.isPasswordInputPresent());

// switch organization link should be available
loginPage.assertSwitchOrganizationLinkAvailability(true);

// click switch organization
loginPage.clickSwitchOrganizationLink();

// org selection page should be shown again
selectOrganizationPage.assertCurrent();
Assert.assertTrue(selectOrganizationPage.isOrganizationButtonPresent(orgA.getAlias()));
Assert.assertTrue(selectOrganizationPage.isOrganizationButtonPresent(orgB.getAlias()));

// select org B this time
selectOrganizationPage.selectOrganization(orgB.getAlias());

// should be on the password page again
Assert.assertTrue(loginPage.isPasswordInputPresent());

// complete login
loginPage.login(memberPassword);
appPage.assertCurrent();

// verify the token contains org B (the final selection), not org A
String code = oauth.parseLoginResponse().getCode();
AccessTokenResponse response = oauth.doAccessTokenRequest(code);
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION));
List<String> organizations = (List<String>) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION);
assertThat(organizations, hasItem(orgB.getAlias()));
assertThat(organizations, not(hasItem(orgA.getAlias())));
}

@Test
public void testSwitchOrganizationNotAvailableForSingleOrgUser() {
OrganizationRepresentation org = createOrganization();
OrganizationResource orgResource = testRealm().organizations().get(org.getId());
UserRepresentation member = addMember(orgResource);

// login with "organization" scope — single org member should NOT see org selection
oauth.clientId("broker-app");
oauth.scope("organization");
loginPage.open(bc.consumerRealmName());
loginPage.loginUsername(member.getEmail());

// should go directly to password page (no org selection for single-org users)
Assert.assertTrue(loginPage.isPasswordInputPresent());

// switch organization link should NOT be available
loginPage.assertSwitchOrganizationLinkAvailability(false);
}

@Test
public void testSwitchOrganizationNotAvailableWithSpecificScope() {
OrganizationRepresentation orgA = createOrganization();
OrganizationRepresentation orgB = createOrganization("org-b");
OrganizationResource orgAResource = testRealm().organizations().get(orgA.getId());
OrganizationResource orgBResource = testRealm().organizations().get(orgB.getId());
UserRepresentation member = addMember(orgAResource);
orgBResource.members().addMember(member.getId()).close();

// login with specific organization scope — no org selection should be shown
oauth.clientId("broker-app");
oauth.scope("organization:" + orgA.getAlias());
loginPage.open(bc.consumerRealmName());
loginPage.loginUsername(member.getEmail());

// should go directly to password page (specific org requested by client)
Assert.assertTrue(loginPage.isPasswordInputPresent());

// switch organization link should NOT be available
loginPage.assertSwitchOrganizationLinkAvailability(false);
}

@Test
public void testSwitchOrganizationNotAvailableWithWildcardScope() {
OrganizationRepresentation orgA = createOrganization();
OrganizationRepresentation orgB = createOrganization("org-b");
OrganizationResource orgAResource = testRealm().organizations().get(orgA.getId());
OrganizationResource orgBResource = testRealm().organizations().get(orgB.getId());
UserRepresentation member = addMember(orgAResource);
orgBResource.members().addMember(member.getId()).close();

// login with wildcard organization scope — no org selection should be shown
oauth.clientId("broker-app");
oauth.scope("organization:*");
loginPage.open(bc.consumerRealmName());
loginPage.loginUsername(member.getEmail());

// should go directly to password page (all orgs mapped)
Assert.assertTrue(loginPage.isPasswordInputPresent());

// switch organization link should NOT be available
loginPage.assertSwitchOrganizationLinkAvailability(false);
}

@Test
public void testUsernamePreservedAfterSwitchOrganization() {
OrganizationRepresentation orgA = createOrganization();
OrganizationRepresentation orgB = createOrganization("org-b");
OrganizationResource orgAResource = testRealm().organizations().get(orgA.getId());
OrganizationResource orgBResource = testRealm().organizations().get(orgB.getId());
UserRepresentation member = addMember(orgAResource);
orgBResource.members().addMember(member.getId()).close();

// login with "organization" scope to trigger org selection
oauth.clientId("broker-app");
oauth.scope("organization");
loginPage.open(bc.consumerRealmName());
loginPage.loginUsername(member.getEmail());

// select org A
selectOrganizationPage.assertCurrent();
selectOrganizationPage.selectOrganization(orgA.getAlias());

// verify username is shown on password page
loginPage.assertAttemptedUsernameAvailability(true);
assertThat(loginPage.getAttemptedUsername(), is(member.getEmail()));

// switch organization
loginPage.clickSwitchOrganizationLink();

// select org B
selectOrganizationPage.assertCurrent();
selectOrganizationPage.selectOrganization(orgB.getAlias());

// username should still be preserved after switching
loginPage.assertAttemptedUsernameAvailability(true);
assertThat(loginPage.getAttemptedUsername(), is(member.getEmail()));
}

private void runOnServer(RunOnServer function) {
testingClient.server(bc.consumerRealmName()).run(function);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ doClickHere=Click here
doImpersonate=Impersonate
doTryAgain=Try again
doTryAnotherWay=Try Another Way
doSwitchOrganization=Switch Organization
doConfirmDelete=Confirm deletion
errorDeletingAccount=Error happened while deleting account
deletingAccountForbidden=You do not have enough permissions to delete your own account, contact admin.
Expand Down
10 changes: 10 additions & 0 deletions themes/src/main/resources/theme/base/login/template.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,16 @@
</form>
</#if>

<#if switchOrganizationEnabled?? && switchOrganizationEnabled>
<form id="kc-switch-organization-form" action="${url.loginAction}" method="post">
<div class="${properties.kcFormGroupClass!}">
<input type="hidden" name="switchOrganization" value="true"/>
<a href="#" id="switch-organization"
false;">${msg("doSwitchOrganization")}</a>
</div>
</form>
</#if>

<#nested "socialProviders">

<#if displayInfo>
Expand Down
10 changes: 10 additions & 0 deletions themes/src/main/resources/theme/keycloak.v2/login/template.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,16 @@
</form>
</#if>

<#if switchOrganizationEnabled?? && switchOrganizationEnabled>
<form id="kc-switch-organization-form" action="${url.loginAction}" method="post" novalidate="novalidate">
<input type="hidden" name="switchOrganization" value="true"/>
<a id="switch-organization" href="javascript:document.forms['kc-switch-organization-form'].requestSubmit()"
class="${properties.kcButtonSecondaryClass} ${properties.kcButtonBlockClass} ${properties.kcMarginTopClass}">
${msg("doSwitchOrganization")}
</a>
</form>
</#if>

<div class="${properties.kcLoginMainFooter!}">
<#nested "socialProviders">

Expand Down
Loading
0