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