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    @SuppressWarnings("unchecked")
296    public <T> Optional<T> getStoredCustomParameter(String parameter)
297    {
298        Optional<Object> sessionAttribute = _sessionAttributeProvider.getSessionAttribute(OAUTH_CUSTOM_PARAMETER + "$" + _id + "#" + parameter);
299        return sessionAttribute.map(str -> (T) JSONValue.parse((String) str));
300    }
301    
302    public Optional<AccessToken> getAccessToken(Redirector redirector) throws ProcessingException, IOException
303    {
304        Optional<AccessToken> accessToken = getStoredAccessToken();
305        
306        if (accessToken.isPresent())
307        {
308            return accessToken;
309        }
310        
311        AuthorizationRequest authRequest = buildAuthorizationCodeRequest();
312        if (!redirector.hasRedirected())
313        {
314            redirector.redirect(false, authRequest.toURI().toString());
315        }
316        return Optional.empty();
317    }
318    
319    /**
320     * Build an authorization request based on the provide information.
321     * 
322     * The request will use a {@code ResponseType#CODE} for the response type.
323     * 
324     * @return an authorization request
325     */
326    protected AuthorizationRequest buildAuthorizationCodeRequest()
327    {
328        // Save the link between state and provider to retrieve provider during callback
329        State state = _generateState();
330        Builder authorizationRequestBuilder = new AuthorizationRequest.Builder(ResponseType.CODE, _clientID)
331                .endpointURI(_authorizationEnpoint)
332                .redirectionURI(_getRedirectUri())
333                .state(state);
334        if (!_scope.isEmpty())
335        {
336            authorizationRequestBuilder.scope(_scope);
337        }
338        return authorizationRequestBuilder.build();
339    }
340    
341    /**
342     * Generate a state for the given provider.
343     * The state will be stored in the provider to be able to retrieve the provider responding
344     * to a authorize request being processed in {@link OAuthCallbackAction}.
345     * We also store the state in session to unsure that the response is linked to the current session.
346     * 
347     * @return the newly generated state
348     */
349    protected State _generateState()
350    {
351        State state = new State();
352        while (!_knownState.add(state))
353        {
354            // if the state already existed, generate a new one
355            state = new State();
356        }
357        Request request = ContextHelper.getRequest(_context);
358        Session session = request.getSession();
359        // only one state is required by session as you can't go through multiple authorization process at the same time
360        session.setAttribute(OAUTH_STATE_SESSION_ATTRIBUTE, state);
361        return state;
362    }
363
364    private URI _getRedirectUri()
365    {
366        // Before returning the standard redirect URI we store the current
367        // request URI in session. This way, the standard redirect will be
368        // able to redirect to the current request making it transparent to
369        // the user.
370        Request request = ContextHelper.getRequest(_context);
371        _storeCurrentRequestUriInSession(request);
372        return _buildAbsoluteURI(request, __OAUTH_AUTHORIZATION_CALLBACK);
373    }
374
375    private void _storeCurrentRequestUriInSession(Request request)
376    {
377        // creation of the actual redirect URI (The one we actually want to go back to)
378        StringBuilder actualRedirectUri = new StringBuilder(request.getRequestURI());
379        String queryString = request.getQueryString();
380        if (StringUtils.isNotEmpty(queryString))
381        {
382            actualRedirectUri.append("?");
383            actualRedirectUri.append(queryString);
384        }
385        
386        // saving the actualRedirectUri to enable its use in "OIDCCallbackAction"
387        Session session = request.getSession(true);
388        session.setAttribute(OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE, actualRedirectUri.toString());
389    }
390    
391    private URI _buildAbsoluteURI(Request request, String path)
392    {
393        StringBuilder uriBuilder = new StringBuilder()
394            .append(request.getScheme())
395            .append("://")
396            .append(request.getServerName());
397        
398        if (request.isSecure())
399        {
400            if (request.getServerPort() != 443)
401            {
402                uriBuilder.append(":");
403                uriBuilder.append(request.getServerPort());
404            }
405        }
406        else
407        {
408            if (request.getServerPort() != 80)
409            {
410                uriBuilder.append(":");
411                uriBuilder.append(request.getServerPort());
412            }
413        }
414
415        uriBuilder.append(request.getContextPath());
416        uriBuilder.append(path);
417        
418        return URI.create(uriBuilder.toString());
419    }
420
421    public AccessToken requestAccessToken(AuthorizationGrant authorizationGrant) throws IOException
422    {
423        TokenRequest tokenRequest = new TokenRequest(getTokenEndpointURI(), getClientAuthentication(), authorizationGrant, getScope());
424        
425        ZonedDateTime requestDate = ZonedDateTime.now();
426        HTTPResponse httpResponse = tokenRequest.toHTTPRequest().send();
427        return _getAccessTokenFromAuthorizationServerResponse(httpResponse, requestDate);
428    }
429    
430    /**
431     * Parse the token response to get the token and store it before returning the newly acquired token
432     * @param httpResponse the response
433     * @param requestDate the date of the request
434     * @return the new token
435     * @throws AccessDeniedException if the response doesn't indicate success
436     */
437    protected AccessToken _getAccessTokenFromAuthorizationServerResponse(HTTPResponse httpResponse, ZonedDateTime requestDate)
438    {
439        TokenResponse tokenResponse;
440        try
441        {
442            tokenResponse = TokenResponse.parse(httpResponse);
443        }
444        catch (ParseException e)
445        {
446            getLogger().error("Token response is invalid. Access will be denied", e);
447            throw new AccessDeniedException("Oauth authorization request failed with invalid response. The response was not parseable");
448        }
449        if (tokenResponse.indicatesSuccess())
450        {
451            AccessTokenResponse successResponse = tokenResponse.toSuccessResponse();
452            
453            return storeTokens(successResponse, requestDate);
454        }
455        else
456        {
457            ErrorObject errorObject = tokenResponse.toErrorResponse().getErrorObject();
458            throw new AccessDeniedException("Oauth authorization request failed with http status '" + errorObject.getHTTPStatusCode()
459            + "', code '" + errorObject.getCode()
460            + "' and description '" + errorObject.getDescription() + "'.");
461        }
462    }
463    
464    /**
465     * Store the tokens information for later uses.
466     * @param response the tokens returned by the successful token request
467     * @param requestDate the request date to compute the expiration date
468     * @return the access token
469     */
470    protected AccessToken storeTokens(AccessTokenResponse response, ZonedDateTime requestDate)
471    {
472        Request request = ContextHelper.getRequest(_context);
473        Session session = request.getSession();
474        
475        Tokens tokens = response.getTokens();
476        
477        
478        AccessToken accessToken = tokens.getAccessToken();
479        session.setAttribute(OAUTH_ACCESS_TOKEN_SESSION_ATTRIBUTE + "$" + _id, accessToken.toJSONObject().toJSONString());
480        session.setAttribute(OAUTH_REFRESH_TOKEN_SESSION_ATTRIBUTE + "$" + _id, tokens.getRefreshToken().toJSONObject().toJSONString());
481        if (accessToken.getLifetime() != 0)
482        {
483            session.setAttribute(OAUTH_ACCESS_TOKEN_EXPIRATION_DATE_SESSION_ATTRIBUTE + "$" + _id, DateUtils.zonedDateTimeToString(requestDate.plusSeconds(accessToken.getLifetime())));
484        }
485        
486        // Store custom parameters if any
487        
488        Map<String, Object> customParameters = response.getCustomParameters();
489        for (String paramName : getCustomParametersName())
490        {
491            Object param = customParameters.get(paramName);
492            // Session attribute must be stored as string for possible serialization
493            session.setAttribute(OAUTH_CUSTOM_PARAMETER + "$" + _id + "#" + paramName, JSONValue.toJSONString(param));
494        }
495        
496        return accessToken;
497    }
498}