OrganizationScope.java

/*
 * 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.Set;
import java.util.function.BiFunction;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;

import org.keycloak.common.util.TriFunction;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeDecorator;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationModel;
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;

/**
 * <p>An enum with utility methods to process the {@link OIDCLoginProtocolFactory#ORGANIZATION} scope.
 *
 * <p>The {@link OrganizationScope} behaves like a dynamic scopes so that access to organizations is granted depending
 * on how the client requests the {@link OIDCLoginProtocolFactory#ORGANIZATION} scope.
 */
public enum OrganizationScope {

    /**
     * Maps to any organization a user is a member. When this scope is requested by clients, all the organizations
     * the user is a member are granted.
     */
    ALL("*"::equals,
            (user, scopes, session) -> {
                if (user == null) {
                    return Stream.empty();
                }
                return getProvider(session).getByMember(user);
            },
            (organizations) -> true,
            (current, previous) -> valueOfScope(current) == null ? previous : current),

    /**
     * Maps to a specific organization the user is a member. When this scope is requested by clients, only the
     * organization specified in the scope is granted.
     */
    SINGLE(StringUtil::isNotBlank,
            (user, scopes, session) -> {
                OrganizationModel organization = parseScopeParameter(scopes)
                        .map(OrganizationScope::parseScopeValue)
                        .map(alias -> getProvider(session).getByAlias(alias))
                        .filter(Objects::nonNull)
                        .findAny()
                        .orElse(null);

                if (organization == null) {
                    return Stream.empty();
                }

                if (user == null || organization.isMember(user)) {
                    return Stream.of(organization);
                }

                return Stream.empty();
            },
            (organizations) -> organizations.findAny().isPresent(),
            (current, previous) -> {
                if (current.equals(previous)) {
                    return current;
                }

                if (OrganizationScope.ALL.equals(valueOfScope(current))) {
                    return previous;
                }

                return null;
            }),

    /**
     * Maps to a single organization if the user is a member of a single organization. When this scope is requested by clients,
     * the user will be asked to select and organization if a member of multiple organizations or, in case the user is a
     * member of a single organization, grant access to that organization.
     */
    ANY(""::equals,
            (user, scopes, session) -> {
                if (user == null) {
                    return Stream.empty();
                }

                List<OrganizationModel> organizations = getProvider(session).getByMember(user).toList();

                if (organizations.size() == 1) {
                    return organizations.stream();
                }

                ClientSessionContext context = (ClientSessionContext) session.getAttribute(ClientSessionContext.class.getName());

                if (context == null) {
                    return Stream.empty();
                }

                AuthenticatedClientSessionModel clientSession = context.getClientSession();
                String orgId = clientSession.getNote(OrganizationModel.ORGANIZATION_ATTRIBUTE);

                if (orgId == null) {
                    return Stream.empty();
                }

                return organizations.stream().filter(o -> o.getId().equals(orgId));
            },
            (organizations) -> true,
            (current, previous) -> {
                if (current.equals(previous)) {
                    return current;
                }

                if (OrganizationScope.ALL.equals(valueOfScope(current))) {
                    return previous;
                }

                return null;
            });

    private static final Pattern SCOPE_PATTERN = Pattern.compile(OIDCLoginProtocolFactory.ORGANIZATION + ":*".replace("*", "(.*)"));

    /**
     * <p>Resolves the value of the scope from its raw format. For instance, {@code organization:<value>} will resolve to {@code <value>}.
     *
     * <p>If no value is provided, like in {@code organization}, an empty string is returned instead.
     */
    private final Predicate<String> valueMatcher;

    /**
     * Resolves the organizations based on the values of the scope.
     */
    private final TriFunction<UserModel, String, KeycloakSession, Stream<OrganizationModel>> valueResolver;

    /**
     * Validate the value of the scope based on how they map to existing organizations.
     */
    private final Predicate<Stream<OrganizationModel>> valueValidator;

    /**
     * Resolves the name of the scope when requesting a scope using a different format.
     */
    private final BiFunction<String, String, String> nameResolver;

    OrganizationScope(Predicate<String> valueMatcher, TriFunction<UserModel, String, KeycloakSession, Stream<OrganizationModel>> valueResolver, Predicate<Stream<OrganizationModel>> valueValidator, BiFunction<String, String, String> nameResolver) {
        this.valueMatcher = valueMatcher;
        this.valueResolver = valueResolver;
        this.valueValidator = valueValidator;
        this.nameResolver = nameResolver;
    }

    /**
     * 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 valueResolver.apply(user, scope, session).filter(OrganizationModel::isEnabled);
    }

    /**
     * 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) {
        OrganizationScope scope = valueOfScope(name);

        if (scope == null) {
            return null;
        }

        KeycloakContext context = session.getContext();
        ClientModel client = context.getClient();
        ClientScopeModel orgScope = getOrganizationClientScope(client, session);

        if (orgScope == null) {
            return null;
        }

        Stream<OrganizationModel> organizations = scope.resolveOrganizations(user, name, session);

        if (valueValidator.test(organizations)) {
            return new ClientScopeDecorator(orgScope, name);
        }

        return null;
    }

    /**
     * <p>Resolves the name of this scope based on the given set of {@code scopes} and the {@code previous} name.
     *
     * <p>The scope name can be mapped to another scope depending on its semantics. Otherwise, it will map to
     * the same name. This method is mainly useful to recognize if a scope previously granted is still valid
     * and can be mapped to the new scope being requested. For instance, when refreshing tokens.
     *
     * @param scopes the scopes to resolve the name from
     * @param previous the previous name of this scope
     * @return the name of the scope
     */
    public String resolveName(Set<String> scopes, String previous) {
        for (String scope : scopes) {
            String resolved = nameResolver.apply(scope, previous);

            if (resolved == null) {
                continue;
            }

            return resolved;
        }

        return null;
    }

    /**
     * Returns a {@link OrganizationScope} instance based on the given {@code rawScope}.
     *
     * @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);
    }
}