From 8e0436715c77f9ea05a0e58b076c89024e11390e Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Thu, 15 Aug 2024 17:56:16 -0300 Subject: [PATCH] Support for ALL and ANY organization scope values Related #31438 Signed-off-by: Pedro Igor --- .../org/keycloak/common/util/TriFunction.java | 33 +++ .../oidc/OrganizationMembershipMapper.java | 21 +- .../mappers/oidc/OrganizationScope.java | 220 ++++++++++++++++++ .../organization/utils/Organizations.java | 51 ++-- .../keycloak/protocol/oidc/TokenManager.java | 60 +++-- .../AuthorizationEndpointChecker.java | 4 +- .../grants/AuthorizationCodeGrantType.java | 2 +- .../oidc/grants/OAuth2GrantTypeBase.java | 4 +- .../oidc/grants/ciba/CibaGrantType.java | 2 +- .../BackchannelAuthenticationEndpoint.java | 2 +- .../oidc/grants/device/DeviceGrantType.java | 2 +- .../managers/AuthenticationManager.java | 2 +- .../admin/ClientScopeEvaluateResource.java | 2 +- ...entScopeEvaluateScopeMappingsResource.java | 2 +- .../util/DefaultClientSessionContext.java | 2 +- .../DeclarativeUserProfileProvider.java | 2 +- .../OrganizationAuthenticationTest.java | 81 ------- .../OrganizationOIDCProtocolMapperTest.java | 177 +++++++++++++- 18 files changed, 483 insertions(+), 186 deletions(-) create mode 100644 common/src/main/java/org/keycloak/common/util/TriFunction.java create mode 100644 services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationScope.java diff --git a/common/src/main/java/org/keycloak/common/util/TriFunction.java b/common/src/main/java/org/keycloak/common/util/TriFunction.java new file mode 100644 index 0000000000..2b9391e70e --- /dev/null +++ b/common/src/main/java/org/keycloak/common/util/TriFunction.java @@ -0,0 +1,33 @@ +/* + * 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.common.util; + +@FunctionalInterface +public interface TriFunction { + + /** + * Applies this function to the given arguments. + * + * @param t the first function argument + * @param u the second function argument + * @param v the third function argument + * @return the function result + */ + R apply(T t, U u, V v); + +} 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 b7c424d76b..76d8a8d714 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 @@ -30,10 +30,7 @@ import org.keycloak.models.ClientSessionContext; import org.keycloak.models.KeycloakSession; import org.keycloak.models.OrganizationModel; import org.keycloak.models.ProtocolMapperModel; -import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; -import org.keycloak.organization.OrganizationProvider; -import org.keycloak.organization.utils.Organizations; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.mappers.AbstractOIDCProtocolMapper; import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper; @@ -45,8 +42,6 @@ import org.keycloak.provider.EnvironmentDependentProviderFactory; import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.representations.IDToken; -import static org.keycloak.organization.utils.Organizations.isEnabledAndOrganizationsPresent; - public class OrganizationMembershipMapper extends AbstractOIDCProtocolMapper implements OIDCAccessTokenMapper, OIDCIDTokenMapper, UserInfoTokenMapper, TokenIntrospectionTokenMapper, EnvironmentDependentProviderFactory { public static final String PROVIDER_ID = "oidc-organization-membership-mapper"; @@ -80,22 +75,14 @@ public class OrganizationMembershipMapper extends AbstractOIDCProtocolMapper imp @Override protected void setClaim(IDToken token, ProtocolMapperModel mappingModel, UserSessionModel userSession, KeycloakSession session, ClientSessionContext clientSessionCtx) { - OrganizationProvider provider = session.getProvider(OrganizationProvider.class); + String rawScopes = clientSessionCtx.getScopeString(); + OrganizationScope scope = OrganizationScope.valueOfScope(rawScopes); - if (!isEnabledAndOrganizationsPresent(provider)) { + if (scope == null) { return; } - OrganizationModel organization = Organizations.resolveOrganizationFromScopeParam(session, clientSessionCtx.getScopeString()); - UserModel user = userSession.getUser(); - Stream organizations = Stream.empty(); - - if (organization == null) { - organizations = provider.getByMember(user).filter(OrganizationModel::isEnabled); - } else if (provider.isMember(organization, user)) { - organizations = Stream.of(organization); - } - + Stream organizations = scope.resolveOrganizations(userSession.getUser(), rawScopes, session); Map> claim = new HashMap<>(); organizations.forEach(o -> claim.put(o.getAlias(), Map.of())); 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 new file mode 100644 index 0000000000..e12eba439f --- /dev/null +++ b/services/src/main/java/org/keycloak/organization/protocol/mappers/oidc/OrganizationScope.java @@ -0,0 +1,220 @@ +/* + * 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.organization.protocol.mappers.oidc; + +import static org.keycloak.organization.utils.Organizations.getProvider; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +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.ClientModel; +import org.keycloak.models.ClientScopeDecorator; +import org.keycloak.models.ClientScopeModel; +import org.keycloak.models.KeycloakContext; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.OrganizationModel; +import org.keycloak.models.UserModel; +import org.keycloak.organization.utils.Organizations; +import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory; +import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.utils.StringUtil; + +/** + * An enum with utility methods to process the {@link OIDCLoginProtocolFactory#ORGANIZATION} scope. + */ +public enum OrganizationScope { + + /** + * Maps to any organization a user is a member + */ + ALL("*"::equals, + (organizations) -> true, + (user, scopes, session) -> { + if (user == null) { + return Stream.empty(); + } + return getProvider(session).getByMember(user).filter(OrganizationModel::isEnabled); + }), + + /** + * Maps to a specific organization the user is a member. + */ + 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); + + if (organization == null) { + return Stream.empty(); + } + + if (user == null || organization.isMember(user)) { + return Stream.of(organization); + } + + return Stream.empty(); + }), + + /** + * Maps to a single organization if the user is a member of a single organization. + */ + ANY(""::equals, + (organizations) -> true, + (user, scopes, session) -> { + List organizations = getProvider(session).getByMember(user).toList(); + + if (organizations.size() == 1) { + return organizations.stream(); + } + + return Stream.empty(); + }); + + 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) { + this.valueMatcher = valueMatcher; + this.valueValidator = valueValidator; + this.orgResolver = orgResolver; + } + + /** + * Returns the organizations mapped from the {@code scope} based on the given {@code user}. + * + * @param user the user. Can be {@code null} depending on how the scope resolves its value. + * @param scope the string referencing the scope + * @param session the session + * @return the organizations mapped to the given {@code user}. Or an empty stream if no organizations were mapped from the {@code scope} parameter. + */ + public Stream resolveOrganizations(UserModel user, String scope, KeycloakSession session) { + if (scope == null) { + return Stream.empty(); + } + return orgResolver.apply(user, scope, session); + } + + /** + * Returns a {@link ClientScopeModel} with the given {@code name} for this scope. + * + * @param name the name of the scope + * @param user the user + * @param session the session + * @return the {@link ClientScopeModel} + */ + public ClientScopeModel toClientScope(String name, UserModel user, KeycloakSession session) { + KeycloakContext context = session.getContext(); + ClientModel client = context.getClient(); + ClientScopeModel orgScope = getOrganizationClientScope(client, session); + + if (orgScope == null) { + return null; + } + + OrganizationScope scope = OrganizationScope.valueOfScope(name); + + if (scope == null) { + return null; + } + + Stream organizations = scope.resolveOrganizations(user, name, session); + + if (valueValidator.test(organizations)) { + return new ClientScopeDecorator(orgScope, name); + } + + return null; + } + + /** + * Returns a {@link OrganizationScope} instance based on the given {@code rawScope}. + * + * @param rawScope the string referencing the scope + * @return the organization scope that maps the given {@code rawScope} + */ + public static OrganizationScope valueOfScope(String rawScope) { + if (rawScope == null) { + return null; + } + return parseScopeParameter(rawScope) + .map(s -> { + for (OrganizationScope scope : values()) { + if (scope.valueMatcher.test(parseScopeValue(s))) { + return scope; + } + } + return null; + }).filter(Objects::nonNull) + .findAny() + .orElse(null); + } + + private static String parseScopeValue(String scope) { + if (!hasOrganizationScope(scope)) { + return null; + } + + if (scope.equals(OIDCLoginProtocolFactory.ORGANIZATION)) { + return ""; + } + + Matcher matcher = SCOPE_PATTERN.matcher(scope); + + if (matcher.matches()) { + return matcher.group(1); + } + + return null; + } + + private ClientScopeModel getOrganizationClientScope(ClientModel client, KeycloakSession session) { + if (!Organizations.isEnabledAndOrganizationsPresent(session)) { + return null; + } + + Map scopes = new HashMap<>(client.getClientScopes(true)); + scopes.putAll(client.getClientScopes(false)); + + return scopes.get(OIDCLoginProtocolFactory.ORGANIZATION); + } + + private static boolean hasOrganizationScope(String scope) { + return scope != null && scope.contains(OIDCLoginProtocolFactory.ORGANIZATION); + } + + private static Stream parseScopeParameter(String rawScope) { + return TokenManager.parseScopeParameter(rawScope) + .filter(OrganizationScope::hasOrganizationScope); + } +} 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 ed6bde83f2..becce79b39 100644 --- a/services/src/main/java/org/keycloak/organization/utils/Organizations.java +++ b/services/src/main/java/org/keycloak/organization/utils/Organizations.java @@ -28,10 +28,9 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.Consumer; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import java.util.stream.Collectors; +import org.keycloak.OAuth2Constants; import org.keycloak.TokenVerifier; import org.keycloak.authentication.actiontoken.inviteorg.InviteOrgActionToken; import org.keycloak.common.Profile; @@ -48,9 +47,8 @@ import org.keycloak.models.OrganizationDomainModel; import org.keycloak.models.OrganizationModel; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; -import org.keycloak.models.UserSessionModel; import org.keycloak.organization.OrganizationProvider; -import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.organization.protocol.mappers.oidc.OrganizationScope; import org.keycloak.representations.idm.OrganizationDomainRepresentation; import org.keycloak.representations.idm.OrganizationRepresentation; import org.keycloak.services.ErrorResponse; @@ -59,8 +57,6 @@ import org.keycloak.utils.StringUtil; public class Organizations { - private static final Pattern SCOPE_PATTERN = Pattern.compile("organization:*".replace("*", "(.*)")); - public static boolean canManageOrganizationGroup(KeycloakSession session, GroupModel group) { if (!Type.ORGANIZATION.equals(group.getType())) { return true; @@ -257,10 +253,21 @@ public class Organizations { AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession(); if (authSession != null) { - Optional organization = ofNullable(authSession.getAuthNote(OrganizationModel.ORGANIZATION_ATTRIBUTE)).map(provider::getById); + OrganizationModel organization = ofNullable(authSession.getAuthNote(OrganizationModel.ORGANIZATION_ATTRIBUTE)) + .map(provider::getById) + .orElseGet(() -> { + String rawScopes = authSession.getClientNote(OAuth2Constants.SCOPE); + OrganizationScope scope = OrganizationScope.valueOfScope(rawScopes); - if (organization.isPresent()) { - return organization.get(); + if (OrganizationScope.SINGLE.equals(scope)) { + return scope.resolveOrganizations(user, rawScopes, session).findAny().orElse(null); + } + + return null; + }); + + if (organization != null) { + return organization; } } @@ -287,31 +294,7 @@ public class Organizations { .orElse(null); } - public static OrganizationModel resolveOrganizationFromScopeParam(KeycloakSession session, String scopeParam) { - return TokenManager.parseScopeParameter(scopeParam) - .map((s) -> resolveOrganizationFromScope(session, s)) - .filter(Objects::nonNull) - .findAny() - .orElse(null); - } - - public static OrganizationModel resolveOrganizationFromScope(KeycloakSession session, String scope) { - if (scope == null) { - return null; - } - - Matcher matcher = SCOPE_PATTERN.matcher(scope); - - if (matcher.matches()) { - return Optional.ofNullable(getProvider(session).getByAlias(matcher.group(1))) - .filter(OrganizationModel::isEnabled) - .orElse(null); - } - - return null; - } - - private static OrganizationProvider getProvider(KeycloakSession session) { + public static OrganizationProvider getProvider(KeycloakSession session) { return session.getProvider(OrganizationProvider.class); } } 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 978b1edfa0..7874cde4ff 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -20,6 +20,7 @@ package org.keycloak.protocol.oidc; import java.util.Collections; import java.util.HashMap; import org.jboss.logging.Logger; +import org.keycloak.common.Profile.Feature; import org.keycloak.http.HttpRequest; import org.keycloak.OAuth2Constants; import org.keycloak.OAuthErrorException; @@ -61,7 +62,7 @@ import org.keycloak.models.light.LightweightUserAdapter; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.SessionExpirationUtils; import org.keycloak.models.utils.RoleUtils; -import org.keycloak.organization.utils.Organizations; +import org.keycloak.organization.protocol.mappers.oidc.OrganizationScope; import org.keycloak.protocol.ProtocolMapper; import org.keycloak.protocol.ProtocolMapperUtils; import org.keycloak.protocol.oidc.mappers.TokenIntrospectionTokenMapper; @@ -587,13 +588,14 @@ public class TokenManager { clientSession.setRedirectUri(authSession.getRedirectUri()); clientSession.setProtocol(authSession.getProtocol()); + String scopeParam = authSession.getClientNote(OAuth2Constants.SCOPE); Set clientScopes; + if (Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES)) { - clientScopes = AuthorizationContextUtil.getClientScopesStreamFromAuthorizationRequestContextWithClient(session, authSession.getClientNote(OAuth2Constants.SCOPE)) + clientScopes = AuthorizationContextUtil.getClientScopesStreamFromAuthorizationRequestContextWithClient(session, scopeParam) .collect(Collectors.toSet()); } else { - clientScopes = authSession.getClientScopes().stream() - .map(id -> KeycloakModelUtils.findClientScopeById(realm, client, id)) + clientScopes = getRequestedClientScopes(session, scopeParam, client, userSession.getUser()) .collect(Collectors.toSet()); } @@ -664,7 +666,7 @@ public class TokenManager { /** Return client itself + all default client scopes of client + optional client scopes requested by scope parameter **/ - public static Stream getRequestedClientScopes(KeycloakSession session, String scopeParam, ClientModel client) { + public static Stream getRequestedClientScopes(KeycloakSession session, String scopeParam, ClientModel client, UserModel user) { // Add all default client scopes automatically and client itself Stream clientScopes = Stream.concat( client.getClientScopes(true).values().stream(), @@ -674,7 +676,6 @@ public class TokenManager { return clientScopes; } - boolean orgEnabled = Organizations.isEnabledAndOrganizationsPresent(session); Map allOptionalScopes = client.getClientScopes(false); // Add optional client scopes requested by scope parameter @@ -686,23 +687,26 @@ public class TokenManager { return scope; } - if (orgEnabled) { - OrganizationModel organization = Organizations.resolveOrganizationFromScope(session, name); - - if (organization != null) { - ClientScopeModel orgScope = allOptionalScopes.get(OIDCLoginProtocolFactory.ORGANIZATION); - return Optional.ofNullable(orgScope) - .map((s) -> new ClientScopeDecorator(s, name)) - .orElse(null); - } - } - - return null; + return tryResolveDynamicClientScope(session, scopeParam, client, user, name); }) .filter(Objects::nonNull), clientScopes).distinct(); } + private static ClientScopeModel tryResolveDynamicClientScope(KeycloakSession session, String scopeParam, ClientModel client, UserModel user, String name) { + if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) { + OrganizationScope orgScope = OrganizationScope.valueOfScope(scopeParam); + + if (orgScope == null) { + return null; + } + + return orgScope.toClientScope(name, user, session); + } + + return null; + } + /** * Check that all the ClientScopes that have been parsed into authorization_resources are actually in the requested scopes * otherwise, the scope wasn't parsed correctly @@ -711,7 +715,7 @@ public class TokenManager { * @param client * @return */ - public static boolean isValidScope(KeycloakSession session, String scopes, AuthorizationRequestContext authorizationRequestContext, ClientModel client) { + public static boolean isValidScope(KeycloakSession session, String scopes, AuthorizationRequestContext authorizationRequestContext, ClientModel client, UserModel user) { if (scopes == null) { return true; } @@ -730,7 +734,7 @@ public class TokenManager { if (authorizationRequestContext == null) { // only true when dynamic scopes feature is enabled - clientScopes = getRequestedClientScopes(session, scopes, client) + clientScopes = getRequestedClientScopes(session, scopes, client, user) .filter(((Predicate) ClientModel.class::isInstance).negate()) .map(ClientScopeModel::getName) .collect(Collectors.toSet()); @@ -753,19 +757,7 @@ public class TokenManager { return false; } - var orgEnabled = Organizations.isEnabledAndOrganizationsPresent(session); - for (String requestedScope : rawScopes) { - if (orgEnabled) { - OrganizationModel organization = Organizations.resolveOrganizationFromScope(session, requestedScope); - - if (organization != null) { - // propagate the organization to authenticators to provide a hint about the organization the client wants to authenticate - session.setAttribute(OrganizationModel.class.getName(), organization); - continue; - } - } - // we also check dynamic scopes in case the client is from a provider that dynamically provides scopes to their clients if (!clientScopes.contains(requestedScope) && client.getDynamicClientScope(requestedScope) == null) { return false; @@ -775,8 +767,8 @@ public class TokenManager { return true; } - public static boolean isValidScope(KeycloakSession session, String scopes, ClientModel client) { - return isValidScope(session, scopes, null, client); + public static boolean isValidScope(KeycloakSession session, String scopes, ClientModel client, UserModel user) { + return isValidScope(session, scopes, null, client, user); } public static Stream parseScopeParameter(String scopeParam) { diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointChecker.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointChecker.java index 67d647e878..c382b6deae 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointChecker.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointChecker.java @@ -226,9 +226,9 @@ public class AuthorizationEndpointChecker { public void checkValidScope() throws AuthorizationCheckException { boolean validScopes; if (Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES)) { - validScopes = TokenManager.isValidScope(session, request.getScope(), request.getAuthorizationRequestContext(), client); + validScopes = TokenManager.isValidScope(session, request.getScope(), request.getAuthorizationRequestContext(), client, null); } else { - validScopes = TokenManager.isValidScope(session, request.getScope(), client); + validScopes = TokenManager.isValidScope(session, request.getScope(), client, null); } if (!validScopes) { ServicesLogger.LOGGER.invalidParameter(OIDCLoginProtocol.SCOPE_PARAM); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/AuthorizationCodeGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/AuthorizationCodeGrantType.java index e55bf604cc..d6b93a1931 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/AuthorizationCodeGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/AuthorizationCodeGrantType.java @@ -187,7 +187,7 @@ public class AuthorizationCodeGrantType extends OAuth2GrantTypeBase { // Compute client scopes again from scope parameter. Check if user still has them granted // (but in code-to-token request, it could just theoretically happen that they are not available) String scopeParam = codeData.getScope(); - Supplier> clientScopesSupplier = () -> TokenManager.getRequestedClientScopes(session, scopeParam, client); + Supplier> clientScopesSupplier = () -> TokenManager.getRequestedClientScopes(session, scopeParam, client, user); if (!TokenManager.verifyConsentStillAvailable(session, user, client, clientScopesSupplier.get())) { String errorMessage = "Client no longer has requested consent from user"; event.detail(Details.REASON, errorMessage); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantTypeBase.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantTypeBase.java index 90f03312e2..bd425a6de9 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantTypeBase.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantTypeBase.java @@ -206,9 +206,9 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType { boolean validScopes; if (Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES)) { AuthorizationRequestContext authorizationRequestContext = AuthorizationContextUtil.getAuthorizationRequestContextFromScopes(session, scope); - validScopes = TokenManager.isValidScope(session, scope, authorizationRequestContext, client); + validScopes = TokenManager.isValidScope(session, scope, authorizationRequestContext, client, null); } else { - validScopes = TokenManager.isValidScope(session, scope, client); + validScopes = TokenManager.isValidScope(session, scope, client, null); } if (!validScopes) { diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/CibaGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/CibaGrantType.java index 730b5017e7..099de71754 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/CibaGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/CibaGrantType.java @@ -188,7 +188,7 @@ public class CibaGrantType extends OAuth2GrantTypeBase { if (!TokenManager .verifyConsentStillAvailable(session, - user, client, TokenManager.getRequestedClientScopes(session, scopeParam, client))) { + user, client, TokenManager.getRequestedClientScopes(session, scopeParam, client, user))) { String errorMessage = "Client no longer has requested consent from user"; event.detail(Details.REASON, errorMessage); event.error(Errors.NOT_ALLOWED); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/BackchannelAuthenticationEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/BackchannelAuthenticationEndpoint.java index 25e94755cf..f41f357e19 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/BackchannelAuthenticationEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/BackchannelAuthenticationEndpoint.java @@ -178,7 +178,7 @@ public class BackchannelAuthenticationEndpoint extends AbstractCibaEndpoint { throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "missing parameter : scope", Response.Status.BAD_REQUEST); } - if (!TokenManager.isValidScope(session, scope, client)) { + if (!TokenManager.isValidScope(session, scope, client, user)) { throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Invalid scopes: " + scope, Response.Status.BAD_REQUEST); } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/device/DeviceGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/device/DeviceGrantType.java index 760c39d869..424d4da368 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/device/DeviceGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/device/DeviceGrantType.java @@ -336,7 +336,7 @@ public class DeviceGrantType extends OAuth2GrantTypeBase { // Compute client scopes again from scope parameter. Check if user still has them granted // (but in device_code-to-token request, it could just theoretically happen that they are not available) String scopeParam = deviceCodeModel.getScope(); - if (!TokenManager.verifyConsentStillAvailable(session, user, client, TokenManager.getRequestedClientScopes(session, scopeParam, client))) { + if (!TokenManager.verifyConsentStillAvailable(session, user, client, TokenManager.getRequestedClientScopes(session, scopeParam, client, user))) { String errorMessage = "Client no longer has requested consent from user"; event.detail(Details.REASON, errorMessage); event.error(Errors.NOT_ALLOWED); diff --git a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java index 0d16231207..23c1aad5a7 100755 --- a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java +++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java @@ -1217,7 +1217,7 @@ public class AuthenticationManager { // todo scope param protocol independent String scopeParam = authSession.getClientNote(OAuth2Constants.SCOPE); - Set requestedClientScopes = TokenManager.getRequestedClientScopes(session, scopeParam, client) + Set requestedClientScopes = TokenManager.getRequestedClientScopes(session, scopeParam, client, user) .map(ClientScopeModel::getId).collect(Collectors.toSet()); authSession.setClientScopes(requestedClientScopes); diff --git a/services/src/main/java/org/keycloak/services/resources/admin/ClientScopeEvaluateResource.java b/services/src/main/java/org/keycloak/services/resources/admin/ClientScopeEvaluateResource.java index 55a2c5daac..13166e82dd 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/ClientScopeEvaluateResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/ClientScopeEvaluateResource.java @@ -129,7 +129,7 @@ public class ClientScopeEvaluateResource { public Stream getGrantedProtocolMappers(@QueryParam("scope") String scopeParam) { auth.clients().requireView(client); - return TokenManager.getRequestedClientScopes(session, scopeParam, client) + return TokenManager.getRequestedClientScopes(session, scopeParam, client, null) .flatMap(mapperContainer -> mapperContainer.getProtocolMappersStream() .filter(current -> isEnabled(session, current) && Objects.equals(current.getProtocol(), client.getProtocol())) .map(current -> toProtocolMapperEvaluationRepresentation(current, mapperContainer))); diff --git a/services/src/main/java/org/keycloak/services/resources/admin/ClientScopeEvaluateScopeMappingsResource.java b/services/src/main/java/org/keycloak/services/resources/admin/ClientScopeEvaluateScopeMappingsResource.java index 90be1682a0..1f4094301c 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/ClientScopeEvaluateScopeMappingsResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/ClientScopeEvaluateScopeMappingsResource.java @@ -112,7 +112,7 @@ public class ClientScopeEvaluateScopeMappingsResource { return roleContainer.getRolesStream(); } - Set clientScopes = TokenManager.getRequestedClientScopes(session, scopeParam, client) + Set clientScopes = TokenManager.getRequestedClientScopes(session, scopeParam, client, null) .collect(Collectors.toSet()); Predicate hasClientScope = role -> 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 b4a47bc292..d2320eb106 100644 --- a/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java +++ b/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java @@ -92,7 +92,7 @@ public class DefaultClientSessionContext implements ClientSessionContext { session.getContext().setClient(clientSession.getClient()); requestedScopes = AuthorizationContextUtil.getClientScopesStreamFromAuthorizationRequestContextWithClient(session, scopeParam); } else { - requestedScopes = TokenManager.getRequestedClientScopes(session, scopeParam, clientSession.getClient()); + requestedScopes = TokenManager.getRequestedClientScopes(session, scopeParam, clientSession.getClient(), clientSession.getUserSession().getUser()); } return new DefaultClientSessionContext(clientSession, requestedScopes.collect(Collectors.toSet()), session); } diff --git a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java index 9d98b5ff1e..3662efae91 100644 --- a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java +++ b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java @@ -87,7 +87,7 @@ public class DeclarativeUserProfileProvider implements UserProfileProvider { String requestedScopesString = authenticationSession.getClientNote(OIDCLoginProtocol.SCOPE_PARAM); ClientModel client = authenticationSession.getClient(); - return getRequestedClientScopes(session, requestedScopesString, client).map((csm) -> csm.getName()).anyMatch(configuredScopes::contains); + return getRequestedClientScopes(session, requestedScopesString, client, context.getUser()).map((csm) -> csm.getName()).anyMatch(configuredScopes::contains); } private final KeycloakSession session; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/authentication/OrganizationAuthenticationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/authentication/OrganizationAuthenticationTest.java index 7baa511ce4..69cd26cd0b 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/authentication/OrganizationAuthenticationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/authentication/OrganizationAuthenticationTest.java @@ -18,37 +18,19 @@ package org.keycloak.testsuite.organization.authentication; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.containsString; -import static org.hamcrest.Matchers.hasItem; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.notNullValue; -import static org.junit.Assert.assertEquals; import static org.keycloak.testsuite.broker.BrokerTestTools.waitForPage; import java.io.IOException; -import java.net.MalformedURLException; -import java.net.URL; -import java.util.Map; import org.hamcrest.Matchers; import org.junit.Test; -import org.keycloak.OAuth2Constants; import org.keycloak.admin.client.resource.OrganizationResource; import org.keycloak.common.Profile.Feature; -import org.keycloak.common.util.MultivaluedHashMap; -import org.keycloak.common.util.UriUtils; -import org.keycloak.models.OrganizationModel; -import org.keycloak.representations.AccessToken; -import org.keycloak.representations.RefreshToken; -import org.keycloak.representations.idm.MemberRepresentation; -import org.keycloak.representations.idm.OrganizationRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testsuite.Assert; import org.keycloak.testsuite.arquillian.annotation.EnableFeature; -import org.keycloak.testsuite.broker.KcOidcBrokerConfiguration; import org.keycloak.testsuite.organization.admin.AbstractOrganizationTest; import org.keycloak.testsuite.updaters.RealmAttributeUpdater; -import org.keycloak.testsuite.util.OAuthClient; @EnableFeature(Feature.ORGANIZATION) public class OrganizationAuthenticationTest extends AbstractOrganizationTest { @@ -123,67 +105,4 @@ public class OrganizationAuthenticationTest extends AbstractOrganizationTest { appPage.assertCurrent(); } } - - @SuppressWarnings("unchecked") - @Test - public void testOrganizationScopeMapsSpecificOrganization() { - 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(); - - // resolve organization based on the organization scope value - oauth.clientId("broker-app"); - oauth.scope("organization:" + orgA.getAlias()); - loginPage.open(bc.consumerRealmName()); - Assert.assertFalse(loginPage.isPasswordInputPresent()); - Assert.assertTrue(loginPage.isSocialButtonPresent(orgA.getAlias() + "-identity-provider")); - Assert.assertFalse(loginPage.isSocialButtonPresent(orgB.getAlias() + "-identity-provider")); - - // 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:" + orgB.getAlias(); - oauth.scope(orgScope); - loginPage.open(bc.consumerRealmName()); - Assert.assertFalse(loginPage.isPasswordInputPresent()); - Assert.assertTrue(loginPage.isSocialButtonPresent(orgB.getAlias() + "-identity-provider")); - Assert.assertFalse(loginPage.isSocialButtonPresent(orgA.getAlias() + "-identity-provider")); - loginPage.loginUsername(member.getEmail()); - Assert.assertTrue(loginPage.isPasswordInputPresent()); - Assert.assertTrue(loginPage.isSocialButtonPresent(orgB.getAlias() + "-identity-provider")); - Assert.assertFalse(loginPage.isSocialButtonPresent(orgA.getAlias() + "-identity-provider")); - 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(orgB.getAlias()), is(true)); - assertThat(response.getRefreshToken(), notNullValue()); - RefreshToken refreshToken = oauth.parseRefreshToken(response.getRefreshToken()); - assertThat(refreshToken.getScope(), containsString(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(orgB.getAlias()), is(true)); - refreshToken = oauth.parseRefreshToken(response.getRefreshToken()); - assertThat(refreshToken.getScope(), containsString(orgScope)); - } - - @Test - public void testInvalidOrganizationScope() throws MalformedURLException { - oauth.clientId("broker-app"); - oauth.scope("organization:unknown"); - oauth.realm(TEST_REALM_NAME); - oauth.openLoginForm(); - MultivaluedHashMap queryParams = UriUtils.decodeQueryString(new URL(driver.getCurrentUrl()).getQuery()); - assertEquals("invalid_scope", queryParams.getFirst("error")); - } } 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 ce66638833..8a8fe4cad9 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 @@ -20,13 +20,18 @@ package org.keycloak.testsuite.organization.mapper; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.is; +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 java.net.MalformedURLException; +import java.net.URL; +import java.time.Duration; import java.util.HashMap; import java.util.Map; -import org.jetbrains.annotations.NotNull; import org.junit.Assert; import org.junit.Test; import org.keycloak.OAuth2Constants; @@ -34,22 +39,30 @@ import org.keycloak.TokenVerifier; import org.keycloak.admin.client.resource.ClientResource; import org.keycloak.admin.client.resource.OrganizationResource; import org.keycloak.common.Profile.Feature; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.common.util.UriUtils; +import org.keycloak.models.OrganizationModel; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.mappers.GroupMembershipMapper; import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper; import org.keycloak.representations.AccessToken; +import org.keycloak.representations.RefreshToken; import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.MemberRepresentation; +import org.keycloak.representations.idm.OrganizationRepresentation; import org.keycloak.representations.idm.ProtocolMapperRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testsuite.arquillian.annotation.EnableFeature; +import org.keycloak.testsuite.broker.KcOidcBrokerConfiguration; import org.keycloak.testsuite.organization.admin.AbstractOrganizationTest; +import org.keycloak.testsuite.util.OAuthClient; import org.keycloak.testsuite.util.OAuthClient.AccessTokenResponse; @EnableFeature(Feature.ORGANIZATION) public class OrganizationOIDCProtocolMapperTest extends AbstractOrganizationTest { @Test - public void testClaim() throws Exception { + public void testPasswordGrantType() throws Exception { OrganizationResource orga = testRealm().organizations().get(createOrganization("org-a").getId()); OrganizationResource orgb = testRealm().organizations().get(createOrganization("org-b").getId()); @@ -62,10 +75,8 @@ public class OrganizationOIDCProtocolMapperTest extends AbstractOrganizationTest Assert.assertTrue(orga.members().getAll().stream().map(UserRepresentation::getId).anyMatch(member.getId()::equals)); Assert.assertTrue(orgb.members().getAll().stream().map(UserRepresentation::getId).anyMatch(member.getId()::equals)); - member = getUserRepresentation(memberEmail); - oauth.clientId("direct-grant"); - oauth.scope("openid organization"); + oauth.scope("openid organization:*"); AccessTokenResponse response = oauth.doGrantAccessTokenRequest("password", memberEmail, memberPassword); assertThat(response.getScope(), containsString("organization")); @@ -99,8 +110,135 @@ public class OrganizationOIDCProtocolMapperTest extends AbstractOrganizationTest assertThat(accessToken.getOtherClaims().get("groups"), nullValue()); } - @NotNull - private static ProtocolMapperRepresentation createGroupMapper() { + @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())); + testRealm().organizations().get(orgB.getId()).members().addMember(member.getId()).close(); + + // resolve organization based on the organization scope value + oauth.clientId("broker-app"); + oauth.scope("organization:" + orgA.getAlias()); + loginPage.open(bc.consumerRealmName()); + org.keycloak.testsuite.Assert.assertFalse(loginPage.isPasswordInputPresent()); + org.keycloak.testsuite.Assert.assertTrue(loginPage.isSocialButtonPresent(orgA.getAlias() + "-identity-provider")); + org.keycloak.testsuite.Assert.assertFalse(loginPage.isSocialButtonPresent(orgB.getAlias() + "-identity-provider")); + + // 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:" + orgB.getAlias(); + oauth.scope(orgScope); + loginPage.open(bc.consumerRealmName()); + org.keycloak.testsuite.Assert.assertFalse(loginPage.isPasswordInputPresent()); + org.keycloak.testsuite.Assert.assertTrue(loginPage.isSocialButtonPresent(orgB.getAlias() + "-identity-provider")); + org.keycloak.testsuite.Assert.assertFalse(loginPage.isSocialButtonPresent(orgA.getAlias() + "-identity-provider")); + loginPage.loginUsername(member.getEmail()); + org.keycloak.testsuite.Assert.assertTrue(loginPage.isPasswordInputPresent()); + org.keycloak.testsuite.Assert.assertTrue(loginPage.isSocialButtonPresent(orgB.getAlias() + "-identity-provider")); + org.keycloak.testsuite.Assert.assertFalse(loginPage.isSocialButtonPresent(orgA.getAlias() + "-identity-provider")); + loginPage.login(memberPassword); + assertScopeAndClaims(orgScope, orgB); + } + + @Test + public void testOrganizationScopeMapsAllOrganizations() { + 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(); + + // resolve organization based on the organization scope value + oauth.clientId("broker-app"); + oauth.scope("organization:" + orgA.getAlias()); + loginPage.open(bc.consumerRealmName()); + org.keycloak.testsuite.Assert.assertFalse(loginPage.isPasswordInputPresent()); + org.keycloak.testsuite.Assert.assertTrue(loginPage.isSocialButtonPresent(orgA.getAlias() + "-identity-provider")); + org.keycloak.testsuite.Assert.assertFalse(loginPage.isSocialButtonPresent(orgB.getAlias() + "-identity-provider")); + + // 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); + 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)); + assertThat(organizations.containsKey(orgA.getAlias()), is(true)); + assertThat(organizations.containsKey(orgB.getAlias()), is(true)); + assertThat(response.getRefreshToken(), notNullValue()); + RefreshToken refreshToken = oauth.parseRefreshToken(response.getRefreshToken()); + assertThat(refreshToken.getScope(), containsString(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(2)); + assertThat(organizations.containsKey(orgA.getAlias()), is(true)); + assertThat(organizations.containsKey(orgB.getAlias()), is(true)); + refreshToken = oauth.parseRefreshToken(response.getRefreshToken()); + assertThat(refreshToken.getScope(), containsString(orgScope)); + } + + @Test + public void testOrganizationScopeAnyMapsSingleOrganization() { + 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()); + + // resolve organization based on the organization scope value + oauth.clientId("broker-app"); + String orgScope = "organization"; + oauth.scope(orgScope); + loginPage.open(bc.consumerRealmName()); + loginPage.loginUsername(member.getEmail()); + 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(); + + OrganizationRepresentation orgB = createOrganization("orgb", Map.of(OrganizationModel.BROKER_PUBLIC, Boolean.TRUE.toString())); + testRealm().organizations().get(orgB.getId()).members().addMember(member.getId()).close(); + 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 + assertThat(response.getScope(), containsString(orgScope)); + accessToken = oauth.verifyToken(response.getAccessToken()); + assertThat(accessToken.getScope(), containsString(orgScope)); + assertThat(accessToken.getOtherClaims().keySet(), not(hasItem(OAuth2Constants.ORGANIZATION))); + } + + @Test + public void testInvalidOrganizationScope() throws MalformedURLException { + oauth.clientId("broker-app"); + oauth.scope("organization:unknown"); + oauth.realm(TEST_REALM_NAME); + oauth.openLoginForm(); + MultivaluedHashMap queryParams = UriUtils.decodeQueryString(new URL(driver.getCurrentUrl()).getQuery()); + assertEquals("invalid_scope", queryParams.getFirst("error")); + } + + private ProtocolMapperRepresentation createGroupMapper() { ProtocolMapperRepresentation groupMapper = new ProtocolMapperRepresentation(); groupMapper.setName("groups"); groupMapper.setProtocolMapper(GroupMembershipMapper.PROVIDER_ID); @@ -112,4 +250,29 @@ public class OrganizationOIDCProtocolMapperTest extends AbstractOrganizationTest groupMapper.setConfig(config); return groupMapper; } + + private void assertScopeAndClaims(String orgScope, OrganizationRepresentation orgA) { + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + 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)); + assertThat(response.getRefreshToken(), notNullValue()); + RefreshToken refreshToken = oauth.parseRefreshToken(response.getRefreshToken()); + assertThat(refreshToken.getScope(), containsString(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)); + refreshToken = oauth.parseRefreshToken(response.getRefreshToken()); + assertThat(refreshToken.getScope(), containsString(orgScope)); + } }