Support for ALL and ANY organization scope values

Related #31438

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2024-08-15 17:56:16 -03:00
parent 709165a90a
commit 8e0436715c
18 changed files with 483 additions and 186 deletions

View file

@ -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<T, U, V, R> {
/**
* 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);
}

View file

@ -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<OrganizationModel> organizations = Stream.empty();
if (organization == null) {
organizations = provider.getByMember(user).filter(OrganizationModel::isEnabled);
} else if (provider.isMember(organization, user)) {
organizations = Stream.of(organization);
}
Stream<OrganizationModel> organizations = scope.resolveOrganizations(userSession.getUser(), rawScopes, session);
Map<String, Map<String, Object>> claim = new HashMap<>();
organizations.forEach(o -> claim.put(o.getAlias(), Map.of()));

View file

@ -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<OrganizationModel> 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<String> valueMatcher;
private final Predicate<Stream<OrganizationModel>> valueValidator;
private final TriFunction<UserModel, String, KeycloakSession, Stream<OrganizationModel>> orgResolver;
OrganizationScope(Predicate<String> valueMatcher, Predicate<Stream<OrganizationModel>> valueValidator, TriFunction<UserModel, String, KeycloakSession, Stream<OrganizationModel>> 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<OrganizationModel> 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<OrganizationModel> 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<String, ClientScopeModel> 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<String> parseScopeParameter(String rawScope) {
return TokenManager.parseScopeParameter(rawScope)
.filter(OrganizationScope::hasOrganizationScope);
}
}

View file

@ -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<OrganizationModel> 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);
}
}

View file

@ -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<ClientScopeModel> 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<ClientScopeModel> getRequestedClientScopes(KeycloakSession session, String scopeParam, ClientModel client) {
public static Stream<ClientScopeModel> getRequestedClientScopes(KeycloakSession session, String scopeParam, ClientModel client, UserModel user) {
// Add all default client scopes automatically and client itself
Stream<ClientScopeModel> clientScopes = Stream.concat(
client.getClientScopes(true).values().stream(),
@ -674,7 +676,6 @@ public class TokenManager {
return clientScopes;
}
boolean orgEnabled = Organizations.isEnabledAndOrganizationsPresent(session);
Map<String, ClientScopeModel> 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<ClientScopeModel>) 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<String> parseScopeParameter(String scopeParam) {

View file

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

View file

@ -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<Stream<ClientScopeModel>> clientScopesSupplier = () -> TokenManager.getRequestedClientScopes(session, scopeParam, client);
Supplier<Stream<ClientScopeModel>> 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);

View file

@ -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) {

View file

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

View file

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

View file

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

View file

@ -1217,7 +1217,7 @@ public class AuthenticationManager {
// todo scope param protocol independent
String scopeParam = authSession.getClientNote(OAuth2Constants.SCOPE);
Set<String> requestedClientScopes = TokenManager.getRequestedClientScopes(session, scopeParam, client)
Set<String> requestedClientScopes = TokenManager.getRequestedClientScopes(session, scopeParam, client, user)
.map(ClientScopeModel::getId).collect(Collectors.toSet());
authSession.setClientScopes(requestedClientScopes);

View file

@ -129,7 +129,7 @@ public class ClientScopeEvaluateResource {
public Stream<ProtocolMapperEvaluationRepresentation> 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)));

View file

@ -112,7 +112,7 @@ public class ClientScopeEvaluateScopeMappingsResource {
return roleContainer.getRolesStream();
}
Set<ClientScopeModel> clientScopes = TokenManager.getRequestedClientScopes(session, scopeParam, client)
Set<ClientScopeModel> clientScopes = TokenManager.getRequestedClientScopes(session, scopeParam, client, null)
.collect(Collectors.toSet());
Predicate<RoleModel> hasClientScope = role ->

View file

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

View file

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

View file

@ -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<String, Object> organizations = (Map<String, Object>) 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<String, Object>) 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<String, String> queryParams = UriUtils.decodeQueryString(new URL(driver.getCurrentUrl()).getQuery());
assertEquals("invalid_scope", queryParams.getFirst("error"));
}
}

View file

@ -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<String, Object> organizations = (Map<String, Object>) 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<String, Object>) 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<String, String> 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<String, Object> organizations = (Map<String, Object>) 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<String, Object>) 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));
}
}