diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/CookieAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/CookieAuthenticator.java index 12e36ca739ea..5f6af31767cb 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/CookieAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/CookieAuthenticator.java @@ -26,7 +26,9 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; +import org.keycloak.organization.protocol.mappers.oidc.OrganizationScope; import org.keycloak.protocol.LoginProtocol; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.messages.Messages; import org.keycloak.sessions.AuthenticationSessionModel; @@ -84,7 +86,12 @@ public void authenticate(AuthenticationFlowContext context) { acrStore.setLevelAuthenticatedToCurrentRequest(previouslyAuthenticatedLevel); authSession.setAuthNote(AuthenticationManager.SSO_AUTH, "true"); context.attachUserSession(authResult.getSession()); - context.success(); + if (OrganizationScope.valueOfScope(authSession.getClientNote(OIDCLoginProtocol.SCOPE_PARAM)) != null) { + // the user must select an organization before authenticating to the current client + context.attempted(); + } else { + context.success(); + } } } } 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 9fb8a968e739..820cf14510bb 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 @@ -17,6 +17,7 @@ package org.keycloak.organization.authentication.authenticators.browser; +import static org.keycloak.authentication.AuthenticatorUtil.isSSOAuthentication; import static org.keycloak.organization.utils.Organizations.getEmailDomain; import static org.keycloak.organization.utils.Organizations.isEnabledAndOrganizationsPresent; import static org.keycloak.organization.utils.Organizations.resolveHomeBroker; @@ -121,7 +122,12 @@ public void action(AuthenticationFlowContext context) { return; } - context.attempted(); + if (isSSOAuthentication(context.getAuthenticationSession())) { + // if re-authenticating in the scope of an organization + context.success(); + } else { + context.attempted(); + } } @Override @@ -134,9 +140,17 @@ private OrganizationModel resolveOrganization(UserModel user, String domain) { HttpRequest request = context.getHttpRequest(); MultivaluedMap parameters = request.getDecodedFormParameters(); List alias = parameters.getOrDefault(OrganizationModel.ORGANIZATION_ATTRIBUTE, List.of()); + AuthenticationSessionModel authSession = context.getAuthenticationSession(); if (alias.isEmpty()) { - return Organizations.resolveOrganization(session, user, domain); + OrganizationModel organization = Organizations.resolveOrganization(session, user, domain); + + if (organization != null) { + // make sure the organization selected by the user is available from the client session when running mappers and issuing tokens + authSession.setClientNote(OrganizationModel.ORGANIZATION_ATTRIBUTE, organization.getId()); + } + + return organization; } OrganizationProvider provider = getOrganizationProvider(); @@ -146,7 +160,6 @@ private OrganizationModel resolveOrganization(UserModel user, String domain) { return null; } - AuthenticationSessionModel authSession = context.getAuthenticationSession(); // make sure the organization selected by the user is available from the client session when running mappers and issuing tokens authSession.setClientNote(OrganizationModel.ORGANIZATION_ATTRIBUTE, organization.getId()); @@ -274,20 +287,26 @@ private void unknownUserChallenge(AuthenticationFlowContext context, Organizatio context.challenge(form.createLoginUsername()); } - private void initialChallenge(AuthenticationFlowContext context){ - // the default challenge won't show any broker but just the identity-first login page and the option to try a different authentication mechanism - LoginFormsProvider form = context.form() - .setAttributeMapper(attributes -> { - attributes.computeIfPresent("social", - (key, bean) -> new OrganizationAwareIdentityProviderBean((IdentityProviderBean) bean, false, true) - ); - attributes.computeIfPresent("auth", - (key, bean) -> new OrganizationAwareAuthenticationContextBean((AuthenticationContextBean) bean, false) - ); - return attributes; - }); + private void initialChallenge(AuthenticationFlowContext context) { + UserModel user = context.getUser(); - context.challenge(form.createLoginUsername()); + if (user == null) { + // the default challenge won't show any broker but just the identity-first login page and the option to try a different authentication mechanism + LoginFormsProvider form = context.form() + .setAttributeMapper(attributes -> { + attributes.computeIfPresent("social", + (key, bean) -> new OrganizationAwareIdentityProviderBean((IdentityProviderBean) bean, false, true) + ); + attributes.computeIfPresent("auth", + (key, bean) -> new OrganizationAwareAuthenticationContextBean((AuthenticationContextBean) bean, false) + ); + return attributes; + }); + + context.challenge(form.createLoginUsername()); + } else if (isSSOAuthentication(context.getAuthenticationSession())) { + shouldUserSelectOrganization(context, user); + } } private boolean hasPublicBrokers(OrganizationModel organization) { diff --git a/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationMembershipMapper.java b/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationMembershipMapper.java index 2c339899116e..3e5f1137c62a 100644 --- a/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationMembershipMapper.java +++ b/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationMembershipMapper.java @@ -110,18 +110,11 @@ public String getHelpText() { @Override protected void setClaim(IDToken token, ProtocolMapperModel model, UserSessionModel userSession, KeycloakSession session, ClientSessionContext clientSessionCtx) { - String rawScopes = clientSessionCtx.getScopeString(); - OrganizationScope scope = OrganizationScope.valueOfScope(rawScopes); - - if (scope == null) { - return; - } - String orgId = clientSessionCtx.getClientSession().getNote(OrganizationModel.ORGANIZATION_ATTRIBUTE); Stream organizations; if (orgId == null) { - organizations = scope.resolveOrganizations(userSession.getUser(), rawScopes, session); + organizations = resolveFromRequestedScopes(session, userSession, clientSessionCtx); } else { organizations = Stream.of(session.getProvider(OrganizationProvider.class).getById(orgId)); } @@ -139,6 +132,18 @@ protected void setClaim(IDToken token, ProtocolMapperModel model, UserSessionMod OIDCAttributeMapperHelper.mapClaim(token, effectiveModel, claim); } + private Stream resolveFromRequestedScopes(KeycloakSession session, UserSessionModel userSession, ClientSessionContext context) { + String rawScopes = context.getScopeString(); + OrganizationScope scope = OrganizationScope.valueOfScope(rawScopes); + + if (scope == null) { + return Stream.empty(); + } + + return scope.resolveOrganizations(userSession.getUser(), rawScopes, session); + + } + private Object resolveValue(ProtocolMapperModel model, List organizations) { if (organizations.isEmpty()) { return null; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/mapper/OrganizationOIDCProtocolMapperTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/mapper/OrganizationOIDCProtocolMapperTest.java index 4aaf5ad16cc8..91fd443e2d34 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/mapper/OrganizationOIDCProtocolMapperTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/mapper/OrganizationOIDCProtocolMapperTest.java @@ -595,6 +595,128 @@ public void testAuthenticatingUsingBroker() { assertEquals(bc.getIDPAlias(), federatedIdentities.get(0).getIdentityProvider()); } + @Test + public void testMapDifferentOrganizationWhenReAuthenticating() { + OrganizationRepresentation orgA = createOrganization("orga", true); + MemberRepresentation member = addMember(testRealm().organizations().get(orgA.getId()), "member@" + orgA.getDomains().iterator().next().getName()); + OrganizationRepresentation orgB = createOrganization("orgb", true); + testRealm().organizations().get(orgB.getId()).members().addMember(member.getId()).close(); + // identity-first login will respect the organization provided in the scope even though the user email maps to a different organization + oauth.clientId("broker-app"); + String originalScope = "organization:orga"; + String orgScope = originalScope; + oauth.scope(orgScope); + loginPage.open(bc.consumerRealmName()); + loginPage.loginUsername(member.getEmail()); + loginPage.login(memberPassword); + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET); + assertThat(response.getScope(), containsString(orgScope)); + AccessToken accessToken = oauth.verifyToken(response.getAccessToken()); + assertThat(accessToken.getScope(), containsString(orgScope)); + assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION)); + List organizations = (List) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION); + assertThat(organizations.size(), is(1)); + assertThat(organizations.contains(orgA.getAlias()), is(true)); + orgScope = "organization:orgb"; + oauth.scope(orgScope); + oauth.realm(bc.consumerRealmName()); + oauth.openLoginForm(); + code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + response = oauth.doAccessTokenRequest(code, KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET); + assertThat(response.getScope(), containsString(orgScope)); + accessToken = oauth.verifyToken(response.getAccessToken()); + assertThat(accessToken.getScope(), containsString(orgScope)); + assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION)); + organizations = (List) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION); + assertThat(organizations.size(), is(1)); + assertThat(organizations.contains(orgB.getAlias()), is(true)); + } + + @Test + public void testSelectOrganizationMapDifferentOrganizationWhenReAuthenticating() { + OrganizationRepresentation orgA = createOrganization("orga", true); + MemberRepresentation member = addMember(testRealm().organizations().get(orgA.getId()), "member@" + orgA.getDomains().iterator().next().getName()); + OrganizationRepresentation orgB = createOrganization("orgb", true); + testRealm().organizations().get(orgB.getId()).members().addMember(member.getId()).close(); + // identity-first login will respect the organization provided in the scope even though the user email maps to a different organization + oauth.clientId("broker-app"); + String originalScope = "organization"; + String orgScope = originalScope; + oauth.scope(orgScope); + loginPage.open(bc.consumerRealmName()); + loginPage.loginUsername(member.getEmail()); + selectOrganizationPage.selectOrganization(orgA.getAlias()); + loginPage.login(memberPassword); + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET); + assertThat(response.getScope(), containsString(orgScope)); + AccessToken accessToken = oauth.verifyToken(response.getAccessToken()); + assertThat(accessToken.getScope(), containsString(orgScope)); + assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION)); + List organizations = (List) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION); + assertThat(organizations.size(), is(1)); + assertThat(organizations.contains(orgA.getAlias()), is(true)); + orgScope = "organization:orgb"; + oauth.scope(orgScope); + oauth.realm(bc.consumerRealmName()); + oauth.openLoginForm(); + code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + response = oauth.doAccessTokenRequest(code, KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET); + assertThat(response.getScope(), containsString(orgScope)); + accessToken = oauth.verifyToken(response.getAccessToken()); + assertThat(accessToken.getScope(), containsString(orgScope)); + assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION)); + organizations = (List) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION); + assertThat(organizations.size(), is(1)); + assertThat(organizations.contains(orgB.getAlias()), is(true)); + } + + @Test + public void testForceSelectingOrganizationWhenReAuthenticatingUsingDifferentClient() { + driver.manage().timeouts().pageLoadTimeout(Duration.ofDays(1)); + OrganizationRepresentation orgA = createOrganization("orga", true); + MemberRepresentation member = addMember(testRealm().organizations().get(orgA.getId()), "member@" + orgA.getDomains().iterator().next().getName()); + OrganizationRepresentation orgB = createOrganization("orgb", true); + testRealm().organizations().get(orgB.getId()).members().addMember(member.getId()).close(); + ClientRepresentation client = testRealm().clients().findByClientId("broker-app").get(0); + client.setId(null); + client.setClientId("broker-app2"); + testRealm().clients().create(client).close(); + // identity-first login will respect the organization provided in the scope even though the user email maps to a different organization + oauth.clientId("broker-app"); + String originalScope = "organization:orga"; + String orgScope = originalScope; + oauth.scope(orgScope); + loginPage.open(bc.consumerRealmName()); + loginPage.loginUsername(member.getEmail()); + loginPage.login(memberPassword); + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET); + assertThat(response.getScope(), containsString(orgScope)); + AccessToken accessToken = oauth.verifyToken(response.getAccessToken()); + assertThat(accessToken.getScope(), containsString(orgScope)); + assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION)); + List organizations = (List) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION); + assertThat(organizations.size(), is(1)); + assertThat(organizations.contains(orgA.getAlias()), is(true)); + orgScope = "organization"; + oauth.clientId("broker-app2"); + oauth.scope(orgScope); + oauth.realm(bc.consumerRealmName()); + oauth.openLoginForm(); + selectOrganizationPage.selectOrganization(orgB.getAlias()); + code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + response = oauth.doAccessTokenRequest(code, KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET); + assertThat(response.getScope(), containsString(orgScope)); + accessToken = oauth.verifyToken(response.getAccessToken()); + assertThat(accessToken.getScope(), containsString(orgScope)); + assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION)); + organizations = (List) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION); + assertThat(organizations.size(), is(1)); + assertThat(organizations.contains(orgB.getAlias()), is(true)); + } + private ProtocolMapperRepresentation createGroupMapper() { ProtocolMapperRepresentation groupMapper = new ProtocolMapperRepresentation(); groupMapper.setName("groups");