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