001/*
002 *  Copyright 2023 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.oauth;
017
018import java.io.IOException;
019import java.net.URI;
020import java.time.ZonedDateTime;
021import java.util.HashSet;
022import java.util.Map;
023import java.util.Optional;
024import java.util.Set;
025
026import org.apache.avalon.framework.configuration.Configurable;
027import org.apache.avalon.framework.configuration.Configuration;
028import org.apache.avalon.framework.configuration.ConfigurationException;
029import org.apache.avalon.framework.context.Context;
030import org.apache.avalon.framework.context.ContextException;
031import org.apache.avalon.framework.context.Contextualizable;
032import org.apache.avalon.framework.service.ServiceException;
033import org.apache.avalon.framework.service.ServiceManager;
034import org.apache.avalon.framework.service.Serviceable;
035import org.apache.cocoon.ProcessingException;
036import org.apache.cocoon.components.ContextHelper;
037import org.apache.cocoon.environment.Redirector;
038import org.apache.cocoon.environment.Request;
039import org.apache.cocoon.environment.Session;
040import org.apache.commons.lang3.StringUtils;
041import org.apache.commons.text.StringTokenizer;
042
043import org.ametys.core.util.DateUtils;
044import org.ametys.core.util.SessionAttributeProvider;
045import org.ametys.runtime.authentication.AccessDeniedException;
046import org.ametys.runtime.config.Config;
047import org.ametys.runtime.plugin.component.AbstractLogEnabled;
048import org.ametys.workspaces.extrausermgt.authentication.oauth.OAuthCallbackAction;
049
050import com.nimbusds.oauth2.sdk.AccessTokenResponse;
051import com.nimbusds.oauth2.sdk.AuthorizationGrant;
052import com.nimbusds.oauth2.sdk.AuthorizationRequest;
053import com.nimbusds.oauth2.sdk.AuthorizationRequest.Builder;
054import com.nimbusds.oauth2.sdk.ErrorObject;
055import com.nimbusds.oauth2.sdk.ParseException;
056import com.nimbusds.oauth2.sdk.RefreshTokenGrant;
057import com.nimbusds.oauth2.sdk.ResponseType;
058import com.nimbusds.oauth2.sdk.Scope;
059import com.nimbusds.oauth2.sdk.TokenRequest;
060import com.nimbusds.oauth2.sdk.TokenResponse;
061import com.nimbusds.oauth2.sdk.auth.ClientAuthentication;
062import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic;
063import com.nimbusds.oauth2.sdk.auth.Secret;
064import com.nimbusds.oauth2.sdk.http.HTTPResponse;
065import com.nimbusds.oauth2.sdk.id.ClientID;
066import com.nimbusds.oauth2.sdk.id.State;
067import com.nimbusds.oauth2.sdk.token.AccessToken;
068import com.nimbusds.oauth2.sdk.token.RefreshToken;
069import com.nimbusds.oauth2.sdk.token.Tokens;
070
071import net.minidev.json.JSONObject;
072import net.minidev.json.JSONValue;
073
074/**
075 * OAuth provider definition to interact with a Nextcloud server.
076 * 
077 * This could be a default OAuth provider definition except for the configuration
078 * part and the handling of the user id provided by Nextcloud as a custom parameter
079 * in the token response.
080 */
081public class DefaultOauthProvider extends AbstractLogEnabled implements OAuthProvider, Configurable, Serviceable, Contextualizable
082{
083    /** OAuth state session attribute */
084    public static final String OAUTH_STATE_SESSION_ATTRIBUTE = "oauth.state";
085    /** OAuth redirect URI to use after a successful token request */
086    public static final String OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE = "oauth.redirect.uri";
087    /** Oauth access token session attribute */
088    public static final String OAUTH_ACCESS_TOKEN_SESSION_ATTRIBUTE = "oauth.access.token";
089    /** Oauth access token expiration date session attribute */
090    public static final String OAUTH_ACCESS_TOKEN_EXPIRATION_DATE_SESSION_ATTRIBUTE = "oauth.access.token.expiration.date";
091    /** Oauth refresh token session attribute */
092    public static final String OAUTH_REFRESH_TOKEN_SESSION_ATTRIBUTE = "oauth.refresh.token";
093    /** Oauth custom parameter session attribute */
094    public static final String OAUTH_CUSTOM_PARAMETER = "oauth.custom.parameter";
095    private static final String __OAUTH_AUTHORIZATION_CALLBACK = "/_extra-user-management/oauth-callback";
096    
097    /** The provider id */
098    protected String _id;
099    /** the oauth client id */
100    protected ClientID _clientID;
101    /** the authentication to use when requesting a token */
102    protected ClientAuthentication _auth;
103    /** the authorization endpoint URI */
104    protected URI _authorizationEnpoint;
105    /** the token endpoint URI */
106    protected URI _tokenEndpointURI;
107    /** the scope for the token */
108    protected Scope _scope;
109    /** the list of custom parameters returned with the token that must be stored for later use*/
110    protected Set<String> _customParameters;
111    private SessionAttributeProvider _sessionAttributeProvider;
112    
113    private Set<State> _knownState = new HashSet<>();
114    private Context _context;
115    
116    public void contextualize(Context context) throws ContextException
117    {
118        _context = context;
119    }
120    
121    public void service(ServiceManager manager) throws ServiceException
122    {
123        _sessionAttributeProvider = (SessionAttributeProvider) manager.lookup(SessionAttributeProvider.ROLE);
124    }
125    
126    public void configure(Configuration configuration) throws ConfigurationException
127    {
128        // Provider id
129        _id = configuration.getAttribute("id");
130        
131        // authentication
132        _clientID = new ClientID(_getConfigValue(configuration.getChild("clientId")));
133        _auth = new ClientSecretBasic(_clientID, new Secret(_getConfigValue(configuration.getChild("secret"))));
134        
135        // endpoints
136        String baseURL = _getConfigValue(configuration.getChild("baseURL"));
137        if (StringUtils.isNotEmpty(baseURL))
138        {
139            _authorizationEnpoint = URI.create(baseURL + _getConfigValue(configuration.getChild("authorizationEndpointPath")));
140            _tokenEndpointURI = URI.create(baseURL + _getConfigValue(configuration.getChild("tokenEndpointPath")));
141        }
142        else
143        {
144            _authorizationEnpoint = URI.create(_getConfigValue(configuration.getChild("authorizationEndpointURI")));
145            _tokenEndpointURI = URI.create(_getConfigValue(configuration.getChild("tokenEndpointURI")));
146        }
147        
148        // scope
149        _scope = new Scope();
150        String scopes = _getConfigValue(configuration.getChild("scopes"));
151        for (String scope : StringTokenizer.getCSVInstance(scopes).getTokenList())
152        {
153            _scope.add(scope);
154        }
155        
156        // token response custom parameters
157        String customParams = _getConfigValue(configuration.getChild("customParams"));
158        _customParameters = new HashSet<>(StringTokenizer.getCSVInstance(customParams).getTokenList());
159    }
160    
161    /**
162     * Get the value of a configuration element either by retrieving the associated value in
163     * the application config or directly the configuration element value
164     * @param cfg a configuration element. Can not be {@code null}
165     * @return the value or null if the value is not present
166     */
167    protected String _getConfigValue(Configuration cfg)
168    {
169        if (cfg.getAttributeAsBoolean("config", false))
170        {
171            // get the config value associated or null if it doesn't exist
172            return Config.getInstance().getValue(cfg.getValue(""));
173        }
174        else
175        {
176            return cfg.getValue(null);
177        }
178    }
179    
180    public ClientID getClientID()
181    {
182        return _clientID;
183    }
184    
185    public URI getAuthorizationEndpointURI()
186    {
187        return _authorizationEnpoint;
188    }
189    
190    public URI getTokenEndpointURI()
191    {
192        return _tokenEndpointURI;
193    }
194    
195    public String getId()
196    {
197        return _id;
198    }
199
200    public ClientAuthentication getClientAuthentication()
201    {
202        return _auth;
203    }
204
205    public Scope getScope()
206    {
207        return _scope;
208    }
209    
210    public Set<String> getCustomParametersName()
211    {
212        return _customParameters;
213    }
214    
215    public boolean isKnownState(State state)
216    {
217        // actually removes it, a state should not be used multiple times
218        return _knownState.remove(state);
219    }
220    
221    public Optional<AccessToken> getStoredAccessToken()
222    {
223        // Get stored attribute
224        Optional<AccessToken> accessToken = _sessionAttributeProvider.getSessionAttribute(OAUTH_ACCESS_TOKEN_SESSION_ATTRIBUTE + "$" + _id)
225            // try to parse it or ignore it
226            .map(s -> {
227                try
228                {
229                    return AccessToken.parse((JSONObject) JSONValue.parseWithException((String) s));
230                }
231                catch (ParseException | net.minidev.json.parser.ParseException e)
232                {
233                    getLogger().warn("Failed to parse the stored access token for provider {}. The token is ignored", _id, e);
234                    return null;
235                }
236            });
237        
238        if (accessToken.isEmpty())
239        {
240            return accessToken;
241        }
242        
243        // Check if the token is still valid
244        Optional<ZonedDateTime> expirationDate = _sessionAttributeProvider.getSessionAttribute(OAUTH_ACCESS_TOKEN_EXPIRATION_DATE_SESSION_ATTRIBUTE + "$" + _id)
245                .map(str -> DateUtils.parseZonedDateTime((String) str));
246        
247        if (expirationDate.isEmpty())
248        {
249            // There is a stored token but no expiration date. This should never happens. Ignore all.
250            getLogger().warn("A token is stored in session for provider {} but the token has no expiration date or an invalid one. The token is ignored.", _id);
251            return Optional.empty();
252        }
253        
254        if (ZonedDateTime.now().isBefore(expirationDate.get()))
255        {
256            return accessToken;
257        }
258        
259        // The token is expired. We try to get a new one silently with the refresh token
260        return _refreshToken();
261    }
262
263    private Optional<AccessToken> _refreshToken()
264    {
265        Optional<Object> tokenAttribute = _sessionAttributeProvider.getSessionAttribute(OAUTH_REFRESH_TOKEN_SESSION_ATTRIBUTE + "$" + _id);
266        Optional<RefreshToken> refreshToken = tokenAttribute.map(str -> {
267            try
268            {
269                return RefreshToken.parse((JSONObject) JSONValue.parseWithException((String) str));
270            }
271            catch (ParseException | net.minidev.json.parser.ParseException e)
272            {
273                getLogger().warn("Failed to parse the stored refresh token for provider {}. The token is ignored", _id, e);
274                return null;
275            }
276        });
277        
278        if (refreshToken.isPresent())
279        {
280            AuthorizationGrant refreshTokenGrant = new RefreshTokenGrant(refreshToken.get());
281            
282            try
283            {
284                return Optional.of(requestAccessToken(refreshTokenGrant));
285            }
286            catch (AccessDeniedException | IOException e)
287            {
288                getLogger().warn("Failed to refresh access token for provider {}", _id, e);
289                return Optional.empty();
290            }
291        }
292        return Optional.empty();
293    }
294    
295    public <T> Optional<T> getStoredCustomParameter(String parameter)
296    {
297        Optional<Object> sessionAttribute = _sessionAttributeProvider.getSessionAttribute(OAUTH_CUSTOM_PARAMETER + "$" + _id + "#" + parameter);
298        return sessionAttribute.map(str -> (T) JSONValue.parse((String) str));
299    }
300    
301    public Optional<AccessToken> getAccessToken(Redirector redirector) throws ProcessingException, IOException
302    {
303        Optional<AccessToken> accessToken = getStoredAccessToken();
304        
305        if (accessToken.isPresent())
306        {
307            return accessToken;
308        }
309        
310        AuthorizationRequest authRequest = buildAuthorizationCodeRequest();
311        if (!redirector.hasRedirected())
312        {
313            redirector.redirect(false, authRequest.toURI().toString());
314        }
315        return Optional.empty();
316    }
317    
318    /**
319     * Build an authorization request based on the provide information.
320     * 
321     * The request will use a {@code ResponseType#CODE} for the response type.
322     * 
323     * @return an authorization request
324     */
325    protected AuthorizationRequest buildAuthorizationCodeRequest()
326    {
327        // Save the link between state and provider to retrieve provider during callback
328        State state = _generateState();
329        Builder authorizationRequestBuilder = new AuthorizationRequest.Builder(ResponseType.CODE, _clientID)
330                .endpointURI(_authorizationEnpoint)
331                .redirectionURI(_getRedirectUri())
332                .state(state);
333        if (!_scope.isEmpty())
334        {
335            authorizationRequestBuilder.scope(_scope);
336        }
337        return authorizationRequestBuilder.build();
338    }
339    
340    /**
341     * Generate a state for the given provider.
342     * The state will be stored in the provider to be able to retrieve the provider responding
343     * to a authorize request being processed in {@link OAuthCallbackAction}.
344     * We also store the state in session to unsure that the response is linked to the current session.
345     * 
346     * @return the newly generated state
347     */
348    protected State _generateState()
349    {
350        State state = new State();
351        while (!_knownState.add(state))
352        {
353            // if the state already existed, generate a new one
354            state = new State();
355        }
356        Request request = ContextHelper.getRequest(_context);
357        Session session = request.getSession();
358        // only one state is required by session as you can't go through multiple authorization process at the same time
359        session.setAttribute(OAUTH_STATE_SESSION_ATTRIBUTE, state);
360        return state;
361    }
362
363    private URI _getRedirectUri()
364    {
365        // Before returning the standard redirect URI we store the current
366        // request URI in session. This way, the standard redirect will be
367        // able to redirect to the current request making it transparent to
368        // the user.
369        Request request = ContextHelper.getRequest(_context);
370        _storeCurrentRequestUriInSession(request);
371        return _buildAbsoluteURI(request, __OAUTH_AUTHORIZATION_CALLBACK);
372    }
373
374    private void _storeCurrentRequestUriInSession(Request request)
375    {
376        // creation of the actual redirect URI (The one we actually want to go back to)
377        StringBuilder actualRedirectUri = new StringBuilder(request.getRequestURI());
378        String queryString = request.getQueryString();
379        if (StringUtils.isNotEmpty(queryString))
380        {
381            actualRedirectUri.append("?");
382            actualRedirectUri.append(queryString);
383        }
384        
385        // saving the actualRedirectUri to enable its use in "OIDCCallbackAction"
386        Session session = request.getSession(true);
387        session.setAttribute(OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE, actualRedirectUri.toString());
388    }
389    
390    private URI _buildAbsoluteURI(Request request, String path)
391    {
392        StringBuilder uriBuilder = new StringBuilder()
393            .append(request.getScheme())
394            .append("://")
395            .append(request.getServerName());
396        
397        if (request.isSecure())
398        {
399            if (request.getServerPort() != 443)
400            {
401                uriBuilder.append(":");
402                uriBuilder.append(request.getServerPort());
403            }
404        }
405        else
406        {
407            if (request.getServerPort() != 80)
408            {
409                uriBuilder.append(":");
410                uriBuilder.append(request.getServerPort());
411            }
412        }
413
414        uriBuilder.append(request.getContextPath());
415        uriBuilder.append(path);
416        
417        return URI.create(uriBuilder.toString());
418    }
419
420    public AccessToken requestAccessToken(AuthorizationGrant authorizationGrant) throws IOException
421    {
422        TokenRequest tokenRequest = new TokenRequest(getTokenEndpointURI(), getClientAuthentication(), authorizationGrant, getScope());
423        
424        ZonedDateTime requestDate = ZonedDateTime.now();
425        HTTPResponse httpResponse = tokenRequest.toHTTPRequest().send();
426        return _getAccessTokenFromAuthorizationServerResponse(httpResponse, requestDate);
427    }
428    
429    /**
430     * Parse the token response to get the token and store it before returning the newly acquired token
431     * @param httpResponse the response
432     * @param requestDate the date of the request
433     * @return the new token
434     * @throws AccessDeniedException if the response doesn't indicate success
435     */
436    protected AccessToken _getAccessTokenFromAuthorizationServerResponse(HTTPResponse httpResponse, ZonedDateTime requestDate)
437    {
438        TokenResponse tokenResponse;
439        try
440        {
441            tokenResponse = TokenResponse.parse(httpResponse);
442        }
443        catch (ParseException e)
444        {
445            getLogger().error("Token response is invalid. Access will be denied", e);
446            throw new AccessDeniedException("Oauth authorization request failed with invalid response. The response was not parseable");
447        }
448        if (tokenResponse.indicatesSuccess())
449        {
450            AccessTokenResponse successResponse = tokenResponse.toSuccessResponse();
451            
452            return storeTokens(successResponse, requestDate);
453        }
454        else
455        {
456            ErrorObject errorObject = tokenResponse.toErrorResponse().getErrorObject();
457            throw new AccessDeniedException("Oauth authorization request failed with http status '" + errorObject.getHTTPStatusCode()
458            + "', code '" + errorObject.getCode()
459            + "' and description '" + errorObject.getDescription() + "'.");
460        }
461    }
462    
463    /**
464     * Store the tokens information for later uses.
465     * @param response the tokens returned by the successful token request
466     * @param requestDate the request date to compute the expiration date
467     * @return the access token
468     */
469    protected AccessToken storeTokens(AccessTokenResponse response, ZonedDateTime requestDate)
470    {
471        Request request = ContextHelper.getRequest(_context);
472        Session session = request.getSession();
473        
474        Tokens tokens = response.getTokens();
475        
476        
477        AccessToken accessToken = tokens.getAccessToken();
478        session.setAttribute(OAUTH_ACCESS_TOKEN_SESSION_ATTRIBUTE + "$" + _id, accessToken.toJSONObject().toJSONString());
479        session.setAttribute(OAUTH_REFRESH_TOKEN_SESSION_ATTRIBUTE + "$" + _id, tokens.getRefreshToken().toJSONObject().toJSONString());
480        if (accessToken.getLifetime() != 0)
481        {
482            session.setAttribute(OAUTH_ACCESS_TOKEN_EXPIRATION_DATE_SESSION_ATTRIBUTE + "$" + _id, DateUtils.zonedDateTimeToString(requestDate.plusSeconds(accessToken.getLifetime())));
483        }
484        
485        // Store custom parameters if any
486        
487        Map<String, Object> customParameters = response.getCustomParameters();
488        for (String paramName : getCustomParametersName())
489        {
490            Object param = customParameters.get(paramName);
491            // Session attribute must be stored as string for possible serialization
492            session.setAttribute(OAUTH_CUSTOM_PARAMETER + "$" + _id + "#" + paramName, JSONValue.toJSONString(param));
493        }
494        
495        return accessToken;
496    }
497}