001/*
002 *  Copyright 2024 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.plugins.extrausermgt.users.aad;
017
018import java.net.URL;
019import java.util.List;
020import java.util.concurrent.CompletableFuture;
021
022import javax.annotation.Nonnull;
023
024import org.apache.avalon.framework.activity.Initializable;
025import org.apache.avalon.framework.component.Component;
026import org.apache.avalon.framework.service.ServiceException;
027import org.apache.avalon.framework.service.ServiceManager;
028import org.apache.avalon.framework.service.Serviceable;
029import org.apache.commons.lang3.StringUtils;
030
031import org.ametys.core.user.CurrentUserProvider;
032import org.ametys.core.user.User;
033import org.ametys.core.user.UserIdentity;
034import org.ametys.core.user.UserManager;
035import org.ametys.core.util.SessionAttributeProvider;
036import org.ametys.plugins.extrausermgt.authentication.msal.AbstractMSALCredentialProvider;
037import org.ametys.runtime.config.Config;
038import org.ametys.runtime.plugin.component.AbstractLogEnabled;
039
040import com.azure.identity.ClientSecretCredential;
041import com.azure.identity.ClientSecretCredentialBuilder;
042import com.microsoft.graph.authentication.BaseAuthenticationProvider;
043import com.microsoft.graph.authentication.TokenCredentialAuthProvider;
044import com.microsoft.graph.requests.GraphServiceClient;
045import com.microsoft.graph.requests.UserRequestBuilder;
046
047import okhttp3.Request;
048
049/**
050 * Provide a Graph client for a user.
051 * Depending on the configuration, the client can either be authenticated
052 * using an application credentials or the current user Entra id token
053 */
054public class GraphClientProvider extends AbstractLogEnabled implements Initializable, Serviceable, Component
055{
056    /** The avalon role */
057    public static final String ROLE = GraphClientProvider.class.getName();
058    
059    private static final String __SCOPE = "https://graph.microsoft.com/.default";
060
061    private GraphServiceClient<Request> _graphClient;
062    
063    private CurrentUserProvider _currentUserProvider;
064    private SessionAttributeProvider _sessionAttributeProvider;
065    private UserManager _userManager;
066    
067    public void service(ServiceManager manager) throws ServiceException
068    {
069        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
070        _sessionAttributeProvider = (SessionAttributeProvider) manager.lookup(SessionAttributeProvider.ROLE);
071        _userManager = (UserManager) manager.lookup(UserManager.ROLE);
072    }
073    
074    public void initialize()
075    {
076        if ((boolean) Config.getInstance().getValue("org.ametys.plugins.extra-user-management.graph.useadmin"))
077        {
078            String appId = Config.getInstance().getValue("org.ametys.plugins.extra-user-management.graph.appid");
079            String clientSecret = Config.getInstance().getValue("org.ametys.plugins.extra-user-management.graph.clientsecret");
080            String tenant = Config.getInstance().getValue("org.ametys.plugins.extra-user-management.graph.tenant");
081            
082            ClientSecretCredential clientSecretCredential = new ClientSecretCredentialBuilder()
083                    .clientId(appId)
084                    .clientSecret(clientSecret)
085                    .tenantId(tenant)
086                    .build();
087            
088            TokenCredentialAuthProvider tokenCredAuthProvider = new TokenCredentialAuthProvider(List.of(__SCOPE), clientSecretCredential);
089            
090            _graphClient = GraphServiceClient.builder()
091                    .authenticationProvider(tokenCredAuthProvider)
092                    .buildClient();
093        }
094    }
095
096    /**
097     * Get a graph client for the user.
098     * No check is performed to ensure that the user is actually a valid graph user
099     * @param userIdentity the user identity
100     * @return the client
101     * @throws GraphClientException when its not possible to retrieve a client for the user
102     */
103    public UserRequestBuilder getUserRequestBuilder(UserIdentity userIdentity) throws GraphClientException
104    {
105        if (_graphClient != null)
106        {
107            return _graphClient.users(_getUserPrincipalName(userIdentity));
108        }
109        else
110        {
111            // This mode can only provide a client for the current user
112            // check that the requested user is the current user before getting the client
113            if (!userIdentity.equals(_currentUserProvider.getUser()))
114            {
115                throw new GraphClientException(userIdentity.toString() + " is not the current user. A graph client can only be retrieved for the current user");
116            }
117            return _sessionAttributeProvider.getSessionAttribute(AbstractMSALCredentialProvider.ACCESS_TOKEN_SESSION_ATTRIBUTE)
118                .filter(String.class::isInstance)
119                .map(String.class::cast)
120                .map(this::_getClientFromToken)
121                .map(GraphServiceClient::me)
122                .orElseThrow(() -> new GraphClientException("The current user " + _currentUserProvider.getUser().toString() + "is not logged with Entra ID."));
123        }
124    }
125    
126    /**
127     * Get an authenticated Graph client
128     * @return a Graph client
129     * @throws GraphClientException when its not possible to retrieve a client for the user
130     */
131    public GraphServiceClient getGraphClient() throws GraphClientException
132    {
133        if (_graphClient != null)
134        {
135            return _graphClient;
136        }
137        else
138        {
139            return _sessionAttributeProvider.getSessionAttribute(AbstractMSALCredentialProvider.ACCESS_TOKEN_SESSION_ATTRIBUTE)
140                .filter(String.class::isInstance)
141                .map(String.class::cast)
142                .map(this::_getClientFromToken)
143                .orElseThrow(() -> new GraphClientException("The current user " + _currentUserProvider.getUser().toString() + "is not logged with Entra ID."));
144        }
145    }
146    
147    private GraphServiceClient _getClientFromToken(String token)
148    {
149        AccessTokenAuthenticationProvider accessTokenAuthenticationProvider = new AccessTokenAuthenticationProvider(token);
150        final GraphServiceClient graphClient = GraphServiceClient
151                .builder()
152                .authenticationProvider(accessTokenAuthenticationProvider)
153                .buildClient();
154
155        return graphClient;
156    }
157    
158    private String _getUserPrincipalName(UserIdentity userIdentity) throws GraphClientException
159    {
160        if ("email".equals(Config.getInstance().getValue("org.ametys.plugins.extra-user-management.graph.authmethod")))
161        {
162            User user = _userManager.getUser(userIdentity);
163            String email = user.getEmail();
164            if (StringUtils.isBlank(email))
165            {
166                throw new GraphClientException("The user '" + userIdentity.toString() + "' has no email address set, thus exchange cannot be contacted using 'email' authentication method");
167            }
168            
169            return email;
170        }
171        else
172        {
173            return userIdentity.getLogin();
174        }
175    }
176    
177    /**
178     * Exception related to the Graph client
179     */
180    public static class GraphClientException extends Exception
181    {
182        /**
183         * Constructs a new exception with the specified detail message.
184         * @param message the detail message.
185         */
186        public GraphClientException(String message)
187        {
188            super(message);
189        }
190    }
191    
192    private static class AccessTokenAuthenticationProvider extends BaseAuthenticationProvider
193    {
194        private String _accessToken;
195
196        public AccessTokenAuthenticationProvider(@Nonnull String accessToken)
197        {
198            this._accessToken = accessToken;
199        }
200
201        @Override
202        public CompletableFuture<String> getAuthorizationTokenAsync(@Nonnull final URL requestUrl)
203        {
204            return CompletableFuture.completedFuture(_accessToken);
205        }
206    }
207}