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