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.authentication.msal;
017
018import java.io.IOException;
019import java.net.URI;
020import java.util.Date;
021import java.util.Map;
022import java.util.Set;
023import java.util.UUID;
024
025import org.apache.avalon.framework.context.Context;
026import org.apache.avalon.framework.context.ContextException;
027import org.apache.avalon.framework.context.Contextualizable;
028import org.apache.cocoon.ProcessingException;
029import org.apache.cocoon.components.ContextHelper;
030import org.apache.cocoon.environment.ObjectModelHelper;
031import org.apache.cocoon.environment.Redirector;
032import org.apache.cocoon.environment.Request;
033import org.apache.cocoon.environment.Session;
034
035import org.ametys.core.authentication.AbstractCredentialProvider;
036import org.ametys.core.authentication.BlockingCredentialProvider;
037import org.ametys.core.authentication.NonBlockingCredentialProvider;
038import org.ametys.core.user.UserIdentity;
039import org.ametys.plugins.extrausermgt.authentication.oidc.AbstractOIDCCredentialProvider;
040import org.ametys.plugins.extrausermgt.authentication.oidc.OIDCBasedCredentialProvider;
041import org.ametys.runtime.authentication.AccessDeniedException;
042import org.ametys.workspaces.extrausermgt.authentication.oidc.OIDCCallbackAction;
043
044import com.microsoft.aad.msal4j.AuthorizationCodeParameters;
045import com.microsoft.aad.msal4j.AuthorizationRequestUrlParameters;
046import com.microsoft.aad.msal4j.AuthorizationRequestUrlParameters.Builder;
047import com.microsoft.aad.msal4j.ClientCredentialFactory;
048import com.microsoft.aad.msal4j.ConfidentialClientApplication;
049import com.microsoft.aad.msal4j.IAccount;
050import com.microsoft.aad.msal4j.IAuthenticationResult;
051import com.microsoft.aad.msal4j.IClientSecret;
052import com.microsoft.aad.msal4j.Prompt;
053import com.microsoft.aad.msal4j.ResponseMode;
054import com.microsoft.aad.msal4j.SilentParameters;
055import com.nimbusds.jwt.SignedJWT;
056
057/**
058 * Sign in through Entra ID, using the OpenId Connect protocol.
059 */
060public abstract class AbstractMSALCredentialProvider extends AbstractCredentialProvider implements OIDCBasedCredentialProvider, BlockingCredentialProvider, NonBlockingCredentialProvider, Contextualizable
061{
062    /** Session attribute to store the access token */
063    public static final String ACCESS_TOKEN_SESSION_ATTRIBUTE = "msal_token";
064    private static final String __ATTRIBUTE_EXPIRATIONDATE = "msal_expirationDate";
065    private static final String __ATTRIBUTE_ACCOUNT = "msal_account";
066    private static final String __ATTRIBUTE_TOKENCACHE = "msal_tokenCache";
067    private static final String __ATTRIBUTE_CODE = "msal_code";
068    private static final String __ATTRIBUTE_SILENT = "msal_silent";
069    private static final String __ATTRIBUTE_STATE = "msal_state";
070    private static final String __ATTRIBUTE_NONCE = "msal_nonce";
071    
072    /** the OIDC app id */
073    protected String _clientID;
074    /** the client secret */
075    protected String _clientSecret;
076    /** whether the user should be explicitely forced to enter its username */
077    protected boolean _prompt;
078    /** whether we should try to silently log the user in */
079    protected boolean _silent;
080
081    private Context _context;
082    
083    @Override
084    public void contextualize(Context context) throws ContextException
085    {
086        _context = context;
087    }
088    
089    /**
090     * Set the mandatory properties. Should be called by implementors as early as possible.
091     * @param cliendId the OIDC app id
092     * @param clientSecret the client secret
093     * @param prompt whether the user should be explicitely forced to enter its username
094     * @param silent whether we should try to silently log the user in
095     */
096    protected void init(String cliendId, String clientSecret, boolean prompt, boolean silent)
097    {
098        _clientID = cliendId;
099        _clientSecret = clientSecret;
100        _prompt = prompt;
101        _silent = silent;
102    }
103    
104    private ConfidentialClientApplication _getClient() throws Exception
105    {
106        IClientSecret secret = ClientCredentialFactory.createFromSecret(_clientSecret);
107        ConfidentialClientApplication client = ConfidentialClientApplication.builder(_clientID, secret)
108                                                                            .authority(getAuthority())
109                                                                            .build();
110        return client;
111    }
112    
113    /**
114     * Returns the URL to send authorization and token requests to.
115     * @return the OIDC authority URL
116     */
117    protected abstract String getAuthority();
118    
119    public String getClientId()
120    {
121        return _clientID;
122    }
123    
124    @Override
125    public boolean blockingIsStillConnected(UserIdentity userIdentity, Redirector redirector) throws Exception
126    {
127        Map objectModel = ContextHelper.getObjectModel(_context);
128        Request request = ObjectModelHelper.getRequest(objectModel);
129        Session session = request.getSession(true);
130        
131        refreshTokenIfNeeded(session);
132        
133        return true;
134    }
135    
136    @Override
137    public boolean nonBlockingIsStillConnected(UserIdentity userIdentity, Redirector redirector) throws Exception
138    {
139        return blockingIsStillConnected(userIdentity, redirector);
140    }
141    
142    @Override
143    public boolean blockingGrantAnonymousRequest()
144    {
145        return false;
146    }
147    
148    @Override
149    public boolean nonBlockingGrantAnonymousRequest()
150    {
151        return false;
152    }
153    
154    private String _getRequestURI(Request request)
155    {
156        StringBuilder uriBuilder = new StringBuilder();
157        if (request.isSecure())
158        {
159            uriBuilder.append("https://").append(request.getServerName());
160            if (request.getServerPort() != 443)
161            {
162                uriBuilder.append(":");
163                uriBuilder.append(request.getServerPort());
164            }
165        }
166        else
167        {
168            uriBuilder.append("http://").append(request.getServerName());
169            if (request.getServerPort() != 80)
170            {
171                uriBuilder.append(":");
172                uriBuilder.append(request.getServerPort());
173            }
174        }
175        
176        uriBuilder.append(request.getContextPath());
177        uriBuilder.append(OIDCCallbackAction.CALLBACK_URL);
178        return uriBuilder.toString();
179    }
180
181    private UserIdentity _login(boolean silent, Redirector redirector) throws Exception
182    {
183        Map objectModel = ContextHelper.getObjectModel(_context);
184        Request request = ObjectModelHelper.getRequest(objectModel);
185        Session session = request.getSession(true);
186        
187        ConfidentialClientApplication client = _getClient();
188
189        String requestURI = _getRequestURI(request);
190        getLogger().debug("MSAL CredentialProvider callback URI: {}", requestURI);
191
192        String storedCode = (String) session.getAttribute(__ATTRIBUTE_CODE);
193        
194        if (storedCode != null)
195        {
196            return _getUserIdentityFromCode(storedCode, session, client, requestURI);
197        }
198        
199        boolean wasSilent = false;
200        if (silent)
201        {
202            wasSilent = "true".equals(session.getAttribute(__ATTRIBUTE_SILENT));
203        }
204        
205        String code = request.getParameter("code");
206        if (code == null)
207        {
208            // sign-in request: redirect the client through the actual authentication process
209            
210            if (wasSilent)
211            {
212                // already passed through this, there should have been some error somewhere
213                return null;
214            }
215            
216            if (silent)
217            {
218                session.setAttribute(__ATTRIBUTE_SILENT, "true");
219            }
220            
221            String state = UUID.randomUUID().toString();
222            session.setAttribute(__ATTRIBUTE_STATE, state);
223            
224            String actualRedirectUri = request.getRequestURI();
225            if (request.getQueryString() != null)
226            {
227                actualRedirectUri += "?" + request.getQueryString();
228            }
229            session.setAttribute(AbstractOIDCCredentialProvider.REDIRECT_URI_SESSION_ATTRIBUTE, actualRedirectUri);
230            
231            String nonce = UUID.randomUUID().toString();
232            session.setAttribute(__ATTRIBUTE_NONCE, nonce);
233            
234            Builder builder = AuthorizationRequestUrlParameters.builder(requestURI, getScopes())
235                                                               .responseMode(ResponseMode.QUERY)
236                                                               .state(state)
237                                                               .nonce(nonce);
238            
239            if (silent)
240            {
241                builder.prompt(Prompt.NONE);
242            }
243            else if (_prompt)
244            {
245                builder.prompt(Prompt.SELECT_ACCOUNT);
246            }
247            
248            AuthorizationRequestUrlParameters parameters = builder.build();
249
250            String authorizationRequestUrl = client.getAuthorizationRequestUrl(parameters).toString();
251            redirector.redirect(false, authorizationRequestUrl);
252            return null;
253        }
254        
255        // we got an authorization code,
256        
257        // but first, check the state to prevent CSRF attacks
258        String storedState = (String) session.getAttribute(__ATTRIBUTE_STATE);
259        String state = request.getParameter("state");
260        
261        if (!storedState.equals(state))
262        {
263            throw new AccessDeniedException("MSAL state mismatch");
264        }
265        
266        session.setAttribute(__ATTRIBUTE_STATE, null);
267        
268        // then store the authorization code
269        session.setAttribute(__ATTRIBUTE_CODE, code);
270        
271        // and finally redirect to initial URI
272        String redirectUri = (String) session.getAttribute(AbstractOIDCCredentialProvider.REDIRECT_URI_SESSION_ATTRIBUTE);
273        redirector.redirect(true, redirectUri);
274        return null;
275    }
276    
277    /**
278     * Returns all needed OIDC scopes. Defaults to ["openid"]
279     * @return all needed OIDC scopes
280     */
281    protected Set<String> getScopes()
282    {
283        return Set.of("openid");
284    }
285    
286    private UserIdentity _getUserIdentityFromCode(String code, Session session, ConfidentialClientApplication client, String requestURI) throws Exception
287    {
288        AuthorizationCodeParameters authParams = AuthorizationCodeParameters.builder(code, new URI(requestURI))
289                                                                            .scopes(getScopes())
290                                                                            .build();
291
292        IAuthenticationResult result = client.acquireToken(authParams).get();
293        
294        // check nonce
295        Map<String, Object> tokenClaims = SignedJWT.parse(result.idToken()).getJWTClaimsSet().getClaims();
296        
297        String storedNonce = (String) session.getAttribute(__ATTRIBUTE_NONCE);
298        String nonce = (String) tokenClaims.get("nonce");
299        
300        if (!storedNonce.equals(nonce))
301        {
302            throw new AccessDeniedException("MSAL nonce mismatch");
303        }
304        
305        session.setAttribute(__ATTRIBUTE_NONCE, null);
306        
307        session.setAttribute(__ATTRIBUTE_EXPIRATIONDATE, result.expiresOnDate());
308        session.setAttribute(__ATTRIBUTE_TOKENCACHE, client.tokenCache().serialize());
309        session.setAttribute(__ATTRIBUTE_ACCOUNT, result.account());
310        
311        session.setAttribute(ACCESS_TOKEN_SESSION_ATTRIBUTE, result.accessToken());
312        
313        // then the user is finally logged in
314        String login = getLogin(result);
315        
316        return new UserIdentity(login, null);
317    }
318    
319    /**
320     * Retrieves the login from the given authentication result
321     * @param result the authentication result
322     * @return the login
323     */
324    protected String getLogin(IAuthenticationResult result)
325    {
326        return result.account().username();
327    }
328    
329    @Override
330    public UserIdentity blockingGetUserIdentity(Redirector redirector) throws Exception
331    {
332        return _login(false, redirector);
333    }
334    
335    public UserIdentity nonBlockingGetUserIdentity(Redirector redirector) throws Exception
336    {
337        if (!_silent)
338        {
339            return null;
340        }
341        
342        return _login(true, redirector);
343    }
344    
345    @Override
346    public void blockingUserNotAllowed(Redirector redirector)
347    {
348        // Nothing to do.
349    }
350    
351    @Override
352    public void nonBlockingUserNotAllowed(Redirector redirector) throws Exception
353    {
354        // Nothing to do.
355    }
356
357    @Override
358    public void blockingUserAllowed(UserIdentity userIdentity, Redirector redirector) throws ProcessingException, IOException
359    {
360        // Nothing to do.
361    }
362    
363    @Override
364    public void nonBlockingUserAllowed(UserIdentity userIdentity, Redirector redirector)
365    {
366        // Empty method, nothing more to do.
367    }
368
369    public boolean requiresNewWindow()
370    {
371        return true;
372    }
373
374    /**
375     * Refresh the access token of the user if needed
376     * @param session the session
377     * @throws Exception when an error occurs
378     */
379    public void refreshTokenIfNeeded(Session session) throws Exception
380    {
381        // this check is also done by the following MSAL code, but it's way faster with just a simple date check
382        Date expDat = (Date) session.getAttribute(__ATTRIBUTE_EXPIRATIONDATE);
383        if (expDat != null && new Date().after(expDat))
384        {
385            ConfidentialClientApplication client = _getClient();
386            
387            IAccount account = (IAccount) session.getAttribute(__ATTRIBUTE_ACCOUNT);
388            String tokenCache = (String) session.getAttribute(__ATTRIBUTE_TOKENCACHE);
389            
390            SilentParameters parameters = SilentParameters.builder(Set.of("openid"), account).build();
391            client.tokenCache().deserialize(tokenCache);
392            IAuthenticationResult result = client.acquireTokenSilently(parameters).get();
393            
394            session.setAttribute(__ATTRIBUTE_EXPIRATIONDATE, result.expiresOnDate());
395            session.setAttribute(__ATTRIBUTE_TOKENCACHE, client.tokenCache().serialize());
396            session.setAttribute(__ATTRIBUTE_ACCOUNT, result.account());
397            
398            session.setAttribute(ACCESS_TOKEN_SESSION_ATTRIBUTE, result.accessToken());
399        }
400    }
401}