From c1f6d5ca64f884fc58160a0ad57c53a7022fb52c Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Tue, 20 Aug 2024 12:25:48 -0300 Subject: [PATCH] Support for selecting an organization when requesting the organization scope Closes #31438 Signed-off-by: Pedro Igor --- .../mapping-organization-claims.adoc | 20 +- .../email/freemarker/beans/ProfileBean.java | 16 ++ .../browser/OrganizationAuthenticator.java | 83 ++++++- .../oidc/OrganizationMembershipMapper.java | 15 +- .../mappers/oidc/OrganizationScope.java | 142 +++++++++--- .../organization/utils/Organizations.java | 38 ++-- .../keycloak/protocol/oidc/TokenManager.java | 19 +- .../util/DefaultClientSessionContext.java | 1 + .../pages/SelectOrganizationPage.java | 73 +++++++ .../admin/AbstractOrganizationTest.java | 4 + .../OrganizationOIDCProtocolMapperTest.java | 204 ++++++++++++++++-- .../login/messages/messages_en.properties | 1 + .../theme/base/login/select-organization.ftl | 23 ++ 13 files changed, 567 insertions(+), 72 deletions(-) create mode 100755 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/SelectOrganizationPage.java create mode 100755 themes/src/main/resources/theme/base/login/select-organization.ftl diff --git a/docs/documentation/server_admin/topics/organizations/mapping-organization-claims.adoc b/docs/documentation/server_admin/topics/organizations/mapping-organization-claims.adoc index df4e31c740..6e669a1a76 100644 --- a/docs/documentation/server_admin/topics/organizations/mapping-organization-claims.adoc +++ b/docs/documentation/server_admin/topics/organizations/mapping-organization-claims.adoc @@ -2,11 +2,9 @@ = Mapping organization claims [role="_abstract"] -When authenticating in the context of an organization, the access token is automatically updated with specific claims -about the organization where the user is a member. - To map organization-specific claims into tokens, a client needs to request the *organization* scope when sending -authorization requests to the server. +authorization requests to the server. When authenticating in the context of an organization, clients can request the `organization` scope to map to tokens information +about the organizations the user is a member. As a result, the token will contain a claim as follows: @@ -19,5 +17,17 @@ As a result, the token will contain a claim as follows: The organization claim can be used by clients (for example, from ID Tokens) and resource servers (for example, from access tokens) to authorize access to protected resources based on the organization where the user is a member. -The organization scope is a built-in optional client scope at the realm. Therefore, this scope is added to any client created +The `organization` scope is a built-in optional client scope at the realm. Therefore, this scope is added to any client created in the realm, by default. + +The `organization` scope is requested using different formats: + +[cols="2*", options="header"] +|=== +|Format +|Description +| `organization` | Maps to a single organization if the user is a member of a single organization. +Otherwise, if a member of multiple organizations, the user will be prompted to select an organization when authenticating to the realm. +| `organization:` | Maps to a single organization with the given alias. +| `organization:*` | Maps to all organizations the user is a member of. +|=== diff --git a/services/src/main/java/org/keycloak/email/freemarker/beans/ProfileBean.java b/services/src/main/java/org/keycloak/email/freemarker/beans/ProfileBean.java index a60b4cb4aa..0399fc6afa 100755 --- a/services/src/main/java/org/keycloak/email/freemarker/beans/ProfileBean.java +++ b/services/src/main/java/org/keycloak/email/freemarker/beans/ProfileBean.java @@ -17,8 +17,11 @@ package org.keycloak.email.freemarker.beans; import org.jboss.logging.Logger; +import org.keycloak.forms.login.freemarker.model.OrganizationBean; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.OrganizationModel; import org.keycloak.models.UserModel; +import org.keycloak.organization.OrganizationProvider; import org.keycloak.representations.userprofile.config.UPAttribute; import org.keycloak.representations.userprofile.config.UPConfig; import org.keycloak.userprofile.UserProfileProvider; @@ -26,6 +29,7 @@ import org.keycloak.userprofile.UserProfileProvider; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.Function; /** * @author Stian Thorgersen @@ -36,10 +40,13 @@ public class ProfileBean { private static final Logger logger = Logger.getLogger(ProfileBean.class); private UserModel user; + private final KeycloakSession session; private final Map attributes = new HashMap<>(); + private List organizations; public ProfileBean(UserModel user, KeycloakSession session) { this.user = user; + this.session = session; if (user.getAttributes() != null) { //TODO: there is no need to set only a single value for attributes but changing this might break existing @@ -80,4 +87,13 @@ public class ProfileBean { public Map getAttributes() { return attributes; } + + public List getOrganizations() { + if (organizations == null) { + organizations = session.getProvider(OrganizationProvider.class).getByMember(user) + .map(o -> new OrganizationBean(o, user)) + .toList(); + } + return organizations; + } } 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 3ce59fdf8f..8ac2c907a0 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 @@ -20,20 +20,25 @@ package org.keycloak.organization.authentication.authenticators.browser; import static org.keycloak.organization.utils.Organizations.getEmailDomain; import static org.keycloak.organization.utils.Organizations.isEnabledAndOrganizationsPresent; import static org.keycloak.organization.utils.Organizations.resolveHomeBroker; -import static org.keycloak.organization.utils.Organizations.resolveOrganization; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Stream; import jakarta.ws.rs.core.MultivaluedMap; +import org.keycloak.OAuth2Constants; import org.keycloak.authentication.AuthenticationFlowContext; import org.keycloak.authentication.AuthenticationFlowError; import org.keycloak.authentication.authenticators.browser.IdentityProviderAuthenticator; +import org.keycloak.email.freemarker.beans.ProfileBean; import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.forms.login.freemarker.model.AuthenticationContextBean; import org.keycloak.forms.login.freemarker.model.IdentityProviderBean; import org.keycloak.http.HttpRequest; import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeycloakContext; import org.keycloak.models.KeycloakSession; import org.keycloak.models.OrganizationModel; import org.keycloak.models.OrganizationModel.IdentityProviderRedirectMode; @@ -45,6 +50,8 @@ import org.keycloak.organization.OrganizationProvider; import org.keycloak.organization.forms.login.freemarker.model.OrganizationAwareAuthenticationContextBean; import org.keycloak.organization.forms.login.freemarker.model.OrganizationAwareIdentityProviderBean; import org.keycloak.organization.forms.login.freemarker.model.OrganizationAwareRealmBean; +import org.keycloak.organization.protocol.mappers.oidc.OrganizationScope; +import org.keycloak.organization.utils.Organizations; import org.keycloak.sessions.AuthenticationSessionModel; public class OrganizationAuthenticator extends IdentityProviderAuthenticator { @@ -64,7 +71,7 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator { return; } - OrganizationModel organization = resolveOrganization(session); + OrganizationModel organization = Organizations.resolveOrganization(session); if (organization == null) { initialChallenge(context); @@ -84,9 +91,12 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator { RealmModel realm = context.getRealm(); UserModel user = resolveUser(context, username); String domain = getEmailDomain(username); - OrganizationModel organization = resolveOrganization(session, user, domain); + OrganizationModel organization = resolveOrganization(user, domain); if (organization == null) { + if (shouldUserSelectOrganization(context, user)) { + return; + } // request does not map to any organization, go to the next step/sub-flow context.attempted(); return; @@ -100,7 +110,7 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator { } if (user == null) { - unkownUserChallenge(context, organization, realm); + unknownUserChallenge(context, organization, realm); return; } @@ -118,6 +128,61 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator { return realm.isOrganizationsEnabled(); } + private OrganizationModel resolveOrganization(UserModel user, String domain) { + KeycloakContext context = session.getContext(); + HttpRequest request = context.getHttpRequest(); + MultivaluedMap parameters = request.getDecodedFormParameters(); + List alias = parameters.getOrDefault(OrganizationModel.ORGANIZATION_ATTRIBUTE, List.of()); + + if (alias.isEmpty()) { + return Organizations.resolveOrganization(session, user, domain); + } + + OrganizationProvider provider = getOrganizationProvider(); + OrganizationModel organization = provider.getByAlias(alias.get(0)); + + if (organization == null) { + 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()); + + return organization; + } + + private boolean shouldUserSelectOrganization(AuthenticationFlowContext context, UserModel user) { + OrganizationProvider provider = getOrganizationProvider(); + AuthenticationSessionModel authSession = context.getAuthenticationSession(); + String rawScope = authSession.getClientNote(OAuth2Constants.SCOPE); + OrganizationScope scope = OrganizationScope.valueOfScope(rawScope); + + if (!OrganizationScope.ANY.equals(scope)) { + return false; + } + + Stream organizations = provider.getByMember(user); + + if (organizations.count() > 1) { + LoginFormsProvider form = context.form(); + form.setAttribute("user", new ProfileBean(user, session)); + form.setAttributeMapper(new Function, Map>() { + @Override + public Map apply(Map attributes) { + attributes.computeIfPresent("auth", + (key, bean) -> new OrganizationAwareAuthenticationContextBean((AuthenticationContextBean) bean, false) + ); + return attributes; + } + }); + context.challenge(form.createForm("select-organization.ftl")); + return true; + } + + return false; + } + private boolean tryRedirectBroker(AuthenticationFlowContext context, OrganizationModel organization, UserModel user, String username, String domain) { // the user has credentials set; do not redirect to allow the user to pick how to authenticate if (user != null && user.credentialManager().getStoredCredentialsStream().findAny().isPresent()) { @@ -158,6 +223,10 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator { } private UserModel resolveUser(AuthenticationFlowContext context, String username) { + if (context.getUser() != null) { + return context.getUser(); + } + if (username == null) { return null; } @@ -166,14 +235,12 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator { RealmModel realm = session.getContext().getRealm(); UserModel user = Optional.ofNullable(users.getUserByEmail(realm, username)).orElseGet(() -> users.getUserByUsername(realm, username)); - if (user != null) { - context.setUser(user); - } + context.setUser(user); return user; } - private void unkownUserChallenge(AuthenticationFlowContext context, OrganizationModel organization, RealmModel realm) { + private void unknownUserChallenge(AuthenticationFlowContext context, OrganizationModel organization, RealmModel realm) { // the user does not exist and is authenticating in the scope of the organization, show the identity-first login page and the // public organization brokers for selection LoginFormsProvider form = context.form() 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 76d8a8d714..01d4328289 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 @@ -21,6 +21,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.stream.Stream; import org.keycloak.Config; @@ -31,6 +32,7 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.OrganizationModel; import org.keycloak.models.ProtocolMapperModel; import org.keycloak.models.UserSessionModel; +import org.keycloak.organization.OrganizationProvider; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.mappers.AbstractOIDCProtocolMapper; import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper; @@ -82,10 +84,19 @@ public class OrganizationMembershipMapper extends AbstractOIDCProtocolMapper imp return; } - Stream organizations = scope.resolveOrganizations(userSession.getUser(), rawScopes, session); + String orgId = clientSessionCtx.getClientSession().getNote(OrganizationModel.ORGANIZATION_ATTRIBUTE); + Stream organizations; + + if (orgId == null) { + organizations = scope.resolveOrganizations(userSession.getUser(), rawScopes, session); + } else { + organizations = Stream.of(session.getProvider(OrganizationProvider.class).getById(orgId)); + } + + Map> claim = new HashMap<>(); - organizations.forEach(o -> claim.put(o.getAlias(), Map.of())); + organizations.filter(Objects::nonNull).forEach(o -> claim.put(o.getAlias(), Map.of())); if (claim.isEmpty()) { return; diff --git a/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationScope.java b/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationScope.java index e12eba439f..9db91d93f9 100644 --- a/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationScope.java +++ b/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationScope.java @@ -23,16 +23,19 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Optional; +import java.util.Set; +import java.util.function.BiFunction; import java.util.function.Predicate; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Stream; import org.keycloak.common.util.TriFunction; +import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeDecorator; import org.keycloak.models.ClientScopeModel; +import org.keycloak.models.ClientSessionContext; import org.keycloak.models.KeycloakContext; import org.keycloak.models.KeycloakSession; import org.keycloak.models.OrganizationModel; @@ -43,33 +46,37 @@ import org.keycloak.protocol.oidc.TokenManager; import org.keycloak.utils.StringUtil; /** - * An enum with utility methods to process the {@link OIDCLoginProtocolFactory#ORGANIZATION} scope. + *

An enum with utility methods to process the {@link OIDCLoginProtocolFactory#ORGANIZATION} scope. + * + *

The {@link OrganizationScope} behaves like a dynamic scopes so that access to organizations is granted depending + * on how the client requests the {@link OIDCLoginProtocolFactory#ORGANIZATION} scope. */ public enum OrganizationScope { /** - * Maps to any organization a user is a member + * Maps to any organization a user is a member. When this scope is requested by clients, all the organizations + * the user is a member are granted. */ ALL("*"::equals, - (organizations) -> true, (user, scopes, session) -> { if (user == null) { return Stream.empty(); } - return getProvider(session).getByMember(user).filter(OrganizationModel::isEnabled); - }), + return getProvider(session).getByMember(user); + }, + (organizations) -> true, + (current, previous) -> valueOfScope(current) == null ? previous : current), /** - * Maps to a specific organization the user is a member. + * Maps to a specific organization the user is a member. When this scope is requested by clients, only the + * organization specified in the scope is granted. */ SINGLE(StringUtil::isNotBlank, - (organizations) -> organizations.findAny().isPresent(), (user, scopes, session) -> { OrganizationModel organization = parseScopeParameter(scopes) .map(OrganizationScope::parseScopeValue) .map(alias -> getProvider(session).getByAlias(alias)) .filter(Objects::nonNull) - .filter(OrganizationModel::isEnabled) .findAny() .orElse(null); @@ -82,32 +89,94 @@ public enum OrganizationScope { } return Stream.empty(); + }, + (organizations) -> organizations.findAny().isPresent(), + (current, previous) -> { + if (current.equals(previous)) { + return current; + } + + if (OrganizationScope.ALL.equals(valueOfScope(current))) { + return previous; + } + + return null; }), /** - * Maps to a single organization if the user is a member of a single organization. + * Maps to a single organization if the user is a member of a single organization. When this scope is requested by clients, + * the user will be asked to select and organization if a member of multiple organizations or, in case the user is a + * member of a single organization, grant access to that organization. */ ANY(""::equals, - (organizations) -> true, (user, scopes, session) -> { + if (user == null) { + return Stream.empty(); + } + List organizations = getProvider(session).getByMember(user).toList(); if (organizations.size() == 1) { return organizations.stream(); } - return Stream.empty(); + ClientSessionContext context = (ClientSessionContext) session.getAttribute(ClientSessionContext.class.getName()); + + if (context == null) { + return Stream.empty(); + } + + AuthenticatedClientSessionModel clientSession = context.getClientSession(); + String orgId = clientSession.getNote(OrganizationModel.ORGANIZATION_ATTRIBUTE); + + if (orgId == null) { + return Stream.empty(); + } + + return organizations.stream().filter(o -> o.getId().equals(orgId)); + }, + (organizations) -> true, + (current, previous) -> { + if (current.equals(previous)) { + return current; + } + + if (OrganizationScope.ALL.equals(valueOfScope(current))) { + return previous; + } + + return null; }); private static final Pattern SCOPE_PATTERN = Pattern.compile(OIDCLoginProtocolFactory.ORGANIZATION + ":*".replace("*", "(.*)")); - private final Predicate valueMatcher; - private final Predicate> valueValidator; - private final TriFunction> orgResolver; - OrganizationScope(Predicate valueMatcher, Predicate> valueValidator, TriFunction> orgResolver) { + /** + *

Resolves the value of the scope from its raw format. For instance, {@code organization:} will resolve to {@code }. + * + *

If no value is provided, like in {@code organization}, an empty string is returned instead. + */ + private final Predicate valueMatcher; + + /** + * Resolves the organizations based on the values of the scope. + */ + private final TriFunction> valueResolver; + + /** + * Validate the value of the scope based on how they map to existing organizations. + */ + private final Predicate> valueValidator; + + /** + * Resolves the name of the scope when requesting a scope using a different format. + */ + private final BiFunction nameResolver; + + OrganizationScope(Predicate valueMatcher, TriFunction> valueResolver, Predicate> valueValidator, BiFunction nameResolver) { this.valueMatcher = valueMatcher; + this.valueResolver = valueResolver; this.valueValidator = valueValidator; - this.orgResolver = orgResolver; + this.nameResolver = nameResolver; } /** @@ -122,7 +191,7 @@ public enum OrganizationScope { if (scope == null) { return Stream.empty(); } - return orgResolver.apply(user, scope, session); + return valueResolver.apply(user, scope, session).filter(OrganizationModel::isEnabled); } /** @@ -134,6 +203,12 @@ public enum OrganizationScope { * @return the {@link ClientScopeModel} */ public ClientScopeModel toClientScope(String name, UserModel user, KeycloakSession session) { + OrganizationScope scope = valueOfScope(name); + + if (scope == null) { + return null; + } + KeycloakContext context = session.getContext(); ClientModel client = context.getClient(); ClientScopeModel orgScope = getOrganizationClientScope(client, session); @@ -142,12 +217,6 @@ public enum OrganizationScope { return null; } - OrganizationScope scope = OrganizationScope.valueOfScope(name); - - if (scope == null) { - return null; - } - Stream organizations = scope.resolveOrganizations(user, name, session); if (valueValidator.test(organizations)) { @@ -157,6 +226,31 @@ public enum OrganizationScope { return null; } + /** + *

Resolves the name of this scope based on the given set of {@code scopes} and the {@code previous} name. + * + *

The scope name can be mapped to another scope depending on its semantics. Otherwise, it will map to + * the same name. This method is mainly useful to recognize if a scope previously granted is still valid + * and can be mapped to the new scope being requested. For instance, when refreshing tokens. + * + * @param scopes the scopes to resolve the name from + * @param previous the previous name of this scope + * @return the name of the scope + */ + public String resolveName(Set scopes, String previous) { + for (String scope : scopes) { + String resolved = nameResolver.apply(scope, previous); + + if (resolved == null) { + continue; + } + + return resolved; + } + + return null; + } + /** * Returns a {@link OrganizationScope} instance based on the given {@code rawScope}. * 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 becce79b39..21f82e8f83 100644 --- a/services/src/main/java/org/keycloak/organization/utils/Organizations.java +++ b/services/src/main/java/org/keycloak/organization/utils/Organizations.java @@ -249,34 +249,34 @@ public class Organizations { } public static OrganizationModel resolveOrganization(KeycloakSession session, UserModel user, String domain) { + Optional organization = Optional.ofNullable((OrganizationModel) session.getAttribute(OrganizationModel.class.getName())); + + if (organization.isPresent()) { + // resolved from current keycloak session + return organization.get(); + } + OrganizationProvider provider = getProvider(session); AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession(); if (authSession != null) { - OrganizationModel organization = ofNullable(authSession.getAuthNote(OrganizationModel.ORGANIZATION_ATTRIBUTE)) + String rawScopes = authSession.getClientNote(OAuth2Constants.SCOPE); + OrganizationScope scope = OrganizationScope.valueOfScope(rawScopes); + + List organizations = ofNullable(authSession.getAuthNote(OrganizationModel.ORGANIZATION_ATTRIBUTE)) .map(provider::getById) - .orElseGet(() -> { - String rawScopes = authSession.getClientNote(OAuth2Constants.SCOPE); - OrganizationScope scope = OrganizationScope.valueOfScope(rawScopes); + .map(List::of) + .orElseGet(() -> scope == null ? List.of() : scope.resolveOrganizations(user, rawScopes, session).toList()); - if (OrganizationScope.SINGLE.equals(scope)) { - return scope.resolveOrganizations(user, rawScopes, session).findAny().orElse(null); - } - - return null; - }); - - if (organization != null) { - return organization; + if (organizations.size() == 1) { + // single organization mapped from authentication session + return organizations.get(0); + } else if (scope != null) { + // organization scope requested but no single organization mapped from the scope + return null; } } - Optional organization = Optional.ofNullable((OrganizationModel) session.getAttribute(OrganizationModel.class.getName())); - - if (organization.isPresent()) { - return organization.get(); - } - organization = ofNullable(user).stream().flatMap(provider::getByMember) .filter(o -> o.isEnabled() && provider.isManagedMember(o, user)) .findAny(); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java index abaa15b07b..756c2967e0 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -396,7 +396,9 @@ public class TokenManager { //if scope parameter is not null, remove every scope that is not part of scope parameter if (scopeParameter != null && ! scopeParameter.isEmpty()) { Set scopeParamScopes = Arrays.stream(scopeParameter.split(" ")).collect(Collectors.toSet()); - oldTokenScope = Arrays.stream(oldTokenScope.split(" ")).filter(sc -> scopeParamScopes.contains(sc)) + oldTokenScope = Arrays.stream(oldTokenScope.split(" ")) + .map(transformScopes(scopeParamScopes)) + .filter(Objects::nonNull) .collect(Collectors.joining(" ")); } @@ -438,6 +440,21 @@ public class TokenManager { return responseBuilder; } + private Function transformScopes(Set requestedScopes) { + return scope -> { + if (requestedScopes.contains(scope)) { + return scope; + } + + if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) { + OrganizationScope oldScope = OrganizationScope.valueOfScope(scope); + return oldScope == null ? null : oldScope.resolveName(requestedScopes, scope); + } + + return null; + }; + } + private void validateTokenReuseForRefresh(KeycloakSession session, RealmModel realm, RefreshToken refreshToken, TokenValidation validation) throws OAuthErrorException { if (realm.isRevokeRefreshToken()) { diff --git a/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java b/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java index d2320eb106..e4ffb72f45 100644 --- a/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java +++ b/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java @@ -75,6 +75,7 @@ public class DefaultClientSessionContext implements ClientSessionContext { this.requestedScopes = requestedScopes; this.clientSession = clientSession; this.session = session; + this.session.setAttribute(ClientSessionContext.class.getName(), this); } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/SelectOrganizationPage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/SelectOrganizationPage.java new file mode 100755 index 0000000000..bae5fd640b --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/SelectOrganizationPage.java @@ -0,0 +1,73 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.testsuite.pages; + +import static org.keycloak.testsuite.util.UIUtils.clickLink; + +import org.jboss.arquillian.test.api.ArquillianResource; +import org.junit.Assert; +import org.keycloak.testsuite.util.DroneUtils; +import org.keycloak.testsuite.util.OAuthClient; +import org.openqa.selenium.By; +import org.openqa.selenium.NoSuchElementException; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; + +public class SelectOrganizationPage extends LanguageComboboxAwarePage { + + @ArquillianResource + protected OAuthClient oauth; + + @FindBy(xpath = "//html") + protected WebElement htmlRoot; + + @Override + public boolean isCurrent() { + try { + return !driver.findElements(By.id("kc-user-organizations")).isEmpty(); + } catch (NoSuchElementException ignore) {} + + return false; + } + + @Override + public void open() { + throw new UnsupportedOperationException(); + } + + public void assertCurrent(String realm) { + String name = getClass().getSimpleName(); + Assert.assertTrue("Expected " + name + " but was " + DroneUtils.getCurrentDriver().getTitle() + " (" + DroneUtils.getCurrentDriver().getCurrentUrl() + ")", + isCurrent(realm)); + } + + public void selectOrganization(String alias) { + WebElement socialButton = findOrganizationButton(alias); + clickLink(socialButton); + } + + public boolean isOrganizationButtonPresent(String alias) { + String id = "organization-" + alias; + return !DroneUtils.getCurrentDriver().findElements(By.id(id)).isEmpty(); + } + + private WebElement findOrganizationButton(String alias) { + String id = "organization-" + alias; + return DroneUtils.getCurrentDriver().findElement(By.id(id)); + } +} 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 ffe3e5f923..d71d50f5ff 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 @@ -54,6 +54,7 @@ import org.keycloak.testsuite.organization.broker.BrokerConfigurationWrapper; import org.keycloak.testsuite.pages.AppPage; import org.keycloak.testsuite.pages.IdpConfirmLinkPage; import org.keycloak.testsuite.pages.LoginPage; +import org.keycloak.testsuite.pages.SelectOrganizationPage; import org.keycloak.testsuite.pages.UpdateAccountInformationPage; import org.keycloak.testsuite.util.TestCleanup; @@ -71,6 +72,9 @@ public abstract class AbstractOrganizationTest extends AbstractAdminTest { @Page protected LoginPage loginPage; + @Page + protected SelectOrganizationPage selectOrganizationPage; + @Page protected IdpConfirmLinkPage idpConfirmLinkPage; 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 8a8fe4cad9..184456960e 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 @@ -25,10 +25,11 @@ import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.nullValue; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import java.net.MalformedURLException; import java.net.URL; -import java.time.Duration; import java.util.HashMap; import java.util.Map; @@ -113,7 +114,6 @@ public class OrganizationOIDCProtocolMapperTest extends AbstractOrganizationTest @SuppressWarnings("unchecked") @Test public void testOrganizationScopeMapsSpecificOrganization() { - driver.manage().timeouts().pageLoadTimeout(Duration.ofDays(1)); OrganizationRepresentation orgA = createOrganization("orga", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString())); MemberRepresentation member = addMember(testRealm().organizations().get(orgA.getId()), "member@" + orgA.getDomains().iterator().next().getName()); OrganizationRepresentation orgB = createOrganization("orgb", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString())); @@ -205,26 +205,204 @@ public class OrganizationOIDCProtocolMapperTest extends AbstractOrganizationTest loginPage.login(memberPassword); assertScopeAndClaims(orgScope, orgA); - String code; - AccessTokenResponse response; - AccessToken accessToken; - - UserRepresentation account = getUserRepresentation(member.getEmail()); - realmsResouce().realm(bc.consumerRealmName()).users().get(account.getId()).logout(); - realmsResouce().realm(bc.providerRealmName()).logoutAll(); + } + @Test + public void testOrganizationScopeAnyAskUserToSelectOrganization() { + OrganizationRepresentation orgA = createOrganization("orga", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString())); + MemberRepresentation member = addMember(testRealm().organizations().get(orgA.getId()), "member@" + orgA.getDomains().iterator().next().getName()); OrganizationRepresentation orgB = createOrganization("orgb", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString())); testRealm().organizations().get(orgB.getId()).members().addMember(member.getId()).close(); + oauth.clientId("broker-app"); + oauth.scope("organization"); + loginPage.open(bc.consumerRealmName()); + loginPage.loginUsername(member.getEmail()); + assertTrue(selectOrganizationPage.isCurrent()); + assertFalse(driver.getPageSource().contains("kc-select-try-another-way-form")); + assertTrue(selectOrganizationPage.isOrganizationButtonPresent(orgA.getAlias())); + assertTrue(selectOrganizationPage.isOrganizationButtonPresent(orgB.getAlias())); + selectOrganizationPage.selectOrganization(orgB.getAlias()); + loginPage.login(memberPassword); + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + AccessTokenResponse response = oauth.doAccessTokenRequest(code, KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET); + // for now, return the organization scope in the response and access token even though no organization is mapped into the token + // once we support the user to select an organization, the selected organization will be mapped + assertThat(response.getScope(), containsString("organization")); + AccessToken accessToken = oauth.verifyToken(response.getAccessToken()); + Map organizations = (Map) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION); + assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION)); + assertThat(organizations.containsKey(orgA.getAlias()), is(false)); + assertThat(organizations.containsKey(orgB.getAlias()), is(true)); + } + + @Test + public void testRefreshTokenWithAllOrganizationsAskingForSpecificOrganization() { + OrganizationRepresentation orgA = createOrganization("orga", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString())); + MemberRepresentation member = addMember(testRealm().organizations().get(orgA.getId()), "member@" + orgA.getDomains().iterator().next().getName()); + OrganizationRepresentation orgB = createOrganization("orgb", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString())); + 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 orgScope = "organization:*"; + oauth.scope(orgScope); loginPage.open(bc.consumerRealmName()); loginPage.loginUsername(member.getEmail()); loginPage.login(memberPassword); - code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); - response = oauth.doAccessTokenRequest(code, KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET); - // for now, return the organization scope in the response and access token even though no organization is mapped into the token - // once we support the user to select an organization, the selected organization will be mapped + 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)); + Map organizations = (Map) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION); + assertThat(organizations.size(), is(2)); + orgScope = "organization:orga"; + oauth.scope(orgScope); + response = oauth.doRefreshTokenRequest(response.getRefreshToken(), 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 = (Map) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION); + assertThat(organizations.size(), is(1)); + assertThat(organizations.containsKey(orgA.getAlias()), is(true)); + } + + @Test + public void testRefreshTokenWithSingleOrganizationsAskingAllOrganizations() { + OrganizationRepresentation orgA = createOrganization("orga", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString())); + MemberRepresentation member = addMember(testRealm().organizations().get(orgA.getId()), "member@" + orgA.getDomains().iterator().next().getName()); + OrganizationRepresentation orgB = createOrganization("orgb", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString())); + 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)); + Map organizations = (Map) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION); + assertThat(organizations.size(), is(1)); + assertThat(organizations.containsKey(orgA.getAlias()), is(true)); + orgScope = "organization:*"; + oauth.scope(orgScope); + response = oauth.doRefreshTokenRequest(response.getRefreshToken(), KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET); + assertThat(response.getScope(), containsString(originalScope)); + accessToken = oauth.verifyToken(response.getAccessToken()); + assertThat(accessToken.getScope(), containsString(originalScope)); + assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION)); + organizations = (Map) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION); + assertThat(organizations.size(), is(1)); + assertThat(organizations.containsKey(orgA.getAlias()), is(true)); + } + + @Test + public void testRefreshTokenWithSingleOrganizationsAskingDifferentOrganization() { + OrganizationRepresentation orgA = createOrganization("orga", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString())); + MemberRepresentation member = addMember(testRealm().organizations().get(orgA.getId()), "member@" + orgA.getDomains().iterator().next().getName()); + OrganizationRepresentation orgB = createOrganization("orgb", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString())); + 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)); + Map organizations = (Map) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION); + assertThat(organizations.size(), is(1)); + assertThat(organizations.containsKey(orgA.getAlias()), is(true)); + orgScope = "organization:orgb"; + oauth.scope(orgScope); + response = oauth.doRefreshTokenRequest(response.getRefreshToken(), KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET); + assertThat(response.getScope(), not(containsString(originalScope))); + accessToken = oauth.verifyToken(response.getAccessToken()); + assertThat(accessToken.getScope(), not(containsString(orgScope))); + assertThat(accessToken.getScope(), not(containsString(originalScope))); + assertThat(accessToken.getOtherClaims().keySet(), not(hasItem(OAuth2Constants.ORGANIZATION))); + } + + @Test + public void testRefreshTokenScopeAnyAskingAllOrganizations() { + OrganizationRepresentation orgA = createOrganization("orga", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString())); + MemberRepresentation member = addMember(testRealm().organizations().get(orgA.getId()), "member@" + orgA.getDomains().iterator().next().getName()); + OrganizationRepresentation orgB = createOrganization("orgb", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString())); + testRealm().organizations().get(orgB.getId()).members().addMember(member.getId()).close(); + oauth.clientId("broker-app"); + String originalScope = "organization"; + oauth.scope(originalScope); + loginPage.open(bc.consumerRealmName()); + loginPage.loginUsername(member.getEmail()); + assertTrue(selectOrganizationPage.isCurrent()); + assertTrue(selectOrganizationPage.isOrganizationButtonPresent(orgA.getAlias())); + assertTrue(selectOrganizationPage.isOrganizationButtonPresent(orgB.getAlias())); + selectOrganizationPage.selectOrganization(orgB.getAlias()); + loginPage.login(memberPassword); + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + AccessTokenResponse response = oauth.doAccessTokenRequest(code, KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET); + // for now, return the organization scope in the response and access token even though no organization is mapped into the token + // once we support the user to select an organization, the selected organization will be mapped + assertThat(response.getScope(), containsString("organization")); + AccessToken accessToken = oauth.verifyToken(response.getAccessToken()); + Map organizations = (Map) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION); + assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION)); + assertThat(organizations.containsKey(orgB.getAlias()), is(true)); + String orgScope = "organization:*"; + oauth.scope(orgScope); + response = oauth.doRefreshTokenRequest(response.getRefreshToken(), KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET); + assertThat(response.getScope(), containsString(originalScope)); + accessToken = oauth.verifyToken(response.getAccessToken()); + assertThat(accessToken.getScope(), containsString(originalScope)); + assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION)); + organizations = (Map) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION); + assertThat(organizations.containsKey(orgB.getAlias()), is(true)); + } + + @Test + public void testRefreshTokenScopeAnyAskingSingleOrganization() { + OrganizationRepresentation orgA = createOrganization("orga", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString())); + MemberRepresentation member = addMember(testRealm().organizations().get(orgA.getId()), "member@" + orgA.getDomains().iterator().next().getName()); + OrganizationRepresentation orgB = createOrganization("orgb", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString())); + testRealm().organizations().get(orgB.getId()).members().addMember(member.getId()).close(); + oauth.clientId("broker-app"); + String originalScope = "organization"; + oauth.scope(originalScope); + loginPage.open(bc.consumerRealmName()); + loginPage.loginUsername(member.getEmail()); + assertTrue(selectOrganizationPage.isCurrent()); + assertTrue(selectOrganizationPage.isOrganizationButtonPresent(orgA.getAlias())); + assertTrue(selectOrganizationPage.isOrganizationButtonPresent(orgB.getAlias())); + selectOrganizationPage.selectOrganization(orgB.getAlias()); + loginPage.login(memberPassword); + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + AccessTokenResponse response = oauth.doAccessTokenRequest(code, KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET); + // for now, return the organization scope in the response and access token even though no organization is mapped into the token + // once we support the user to select an organization, the selected organization will be mapped + assertThat(response.getScope(), containsString("organization")); + AccessToken accessToken = oauth.verifyToken(response.getAccessToken()); + Map organizations = (Map) accessToken.getOtherClaims().get(OAuth2Constants.ORGANIZATION); + assertThat(accessToken.getOtherClaims().keySet(), hasItem(OAuth2Constants.ORGANIZATION)); + assertThat(organizations.containsKey(orgB.getAlias()), is(true)); + String orgScope = "organization:orgb"; + oauth.scope(orgScope); + response = oauth.doRefreshTokenRequest(response.getRefreshToken(), KcOidcBrokerConfiguration.CONSUMER_BROKER_APP_SECRET); + assertThat(response.getScope(), not(containsString(orgScope))); + accessToken = oauth.verifyToken(response.getAccessToken()); + assertThat(accessToken.getScope(), not(containsString(orgScope))); assertThat(accessToken.getOtherClaims().keySet(), not(hasItem(OAuth2Constants.ORGANIZATION))); } diff --git a/themes/src/main/resources/theme/base/login/messages/messages_en.properties b/themes/src/main/resources/theme/base/login/messages/messages_en.properties index 5c3d5c57db..f63ba6c512 100644 --- a/themes/src/main/resources/theme/base/login/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/login/messages/messages_en.properties @@ -523,3 +523,4 @@ error-invalid-multivalued-size=Attribute {0} must have at least {1} and at most organization.confirm-membership.title=You are about to join organization ${kc.org.name} organization.confirm-membership=By clicking on the link below, you will become a member of the {0} organization: organization.member.register.title=Create an account to join the ${kc.org.name} organization +organization.select=Select an organization to proceed: \ No newline at end of file diff --git a/themes/src/main/resources/theme/base/login/select-organization.ftl b/themes/src/main/resources/theme/base/login/select-organization.ftl new file mode 100755 index 0000000000..541448a06c --- /dev/null +++ b/themes/src/main/resources/theme/base/login/select-organization.ftl @@ -0,0 +1,23 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout; section> + <#if section = "form"> +

+
+

${msg("organization.select")}

+ + +
+ +
+ + +