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