001/*
002 * (C) Copyright 2014 Nuxeo SA (http://nuxeo.com/) and others.
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 *
016 * Contributors:
017 *     Arnaud Kervern
018 */
019package org.nuxeo.ecm.platform.oauth2.request;
020
021import static org.nuxeo.ecm.platform.oauth2.Constants.CLIENT_ID_PARAM;
022import static org.nuxeo.ecm.platform.oauth2.Constants.CODE_CHALLENGE_METHODS_SUPPORTED;
023import static org.nuxeo.ecm.platform.oauth2.Constants.CODE_CHALLENGE_METHOD_PARAM;
024import static org.nuxeo.ecm.platform.oauth2.Constants.CODE_CHALLENGE_METHOD_PLAIN;
025import static org.nuxeo.ecm.platform.oauth2.Constants.CODE_CHALLENGE_METHOD_S256;
026import static org.nuxeo.ecm.platform.oauth2.Constants.CODE_CHALLENGE_PARAM;
027import static org.nuxeo.ecm.platform.oauth2.Constants.CODE_RESPONSE_TYPE;
028import static org.nuxeo.ecm.platform.oauth2.Constants.REDIRECT_URI_PARAM;
029import static org.nuxeo.ecm.platform.oauth2.Constants.RESPONSE_TYPE_PARAM;
030import static org.nuxeo.ecm.platform.oauth2.Constants.SCOPE_PARAM;
031
032import java.io.Serializable;
033import java.security.Principal;
034import java.util.Date;
035import java.util.HashMap;
036import java.util.List;
037import java.util.Map;
038
039import javax.servlet.http.HttpServletRequest;
040
041import org.apache.commons.codec.binary.Base64;
042import org.apache.commons.codec.digest.DigestUtils;
043import org.apache.commons.collections.CollectionUtils;
044import org.apache.commons.lang.RandomStringUtils;
045import org.apache.commons.lang.StringUtils;
046import org.apache.commons.logging.Log;
047import org.apache.commons.logging.LogFactory;
048import org.nuxeo.ecm.core.transientstore.api.TransientStore;
049import org.nuxeo.ecm.core.transientstore.api.TransientStoreService;
050import org.nuxeo.ecm.platform.oauth2.OAuth2Error;
051import org.nuxeo.ecm.platform.oauth2.clients.OAuth2Client;
052import org.nuxeo.ecm.platform.oauth2.clients.OAuth2ClientService;
053import org.nuxeo.runtime.api.Framework;
054
055/**
056 * @author <a href="mailto:ak@nuxeo.com">Arnaud Kervern</a>
057 * @since 5.9.2
058 */
059public class AuthorizationRequest extends OAuth2Request {
060
061    private static final Log log = LogFactory.getLog(AuthorizationRequest.class);
062
063    public static final String MISSING_REQUIRED_FIELD_MESSAGE = "Missing required field \"%s\".";
064
065    public static final String STORE_NAME = "authorizationRequestStore";
066
067    protected String responseType;
068
069    protected String scope;
070
071    protected Date creationDate;
072
073    protected String authorizationCode;
074
075    protected String username;
076
077    protected String codeChallenge;
078
079    protected String codeChallengeMethod;
080
081    public static AuthorizationRequest fromRequest(HttpServletRequest request) {
082        return new AuthorizationRequest(request);
083    }
084
085    public static AuthorizationRequest fromMap(Map<String, Serializable> map) {
086        return new AuthorizationRequest(map);
087    }
088
089    public static void store(String key, AuthorizationRequest authorizationRequest) {
090        TransientStoreService transientStoreService = Framework.getService(TransientStoreService.class);
091        TransientStore store = transientStoreService.getStore(STORE_NAME);
092        store.putParameters(key, authorizationRequest.toMap());
093    }
094
095    public static AuthorizationRequest get(String key) {
096        TransientStoreService transientStoreService = Framework.getService(TransientStoreService.class);
097        TransientStore store = transientStoreService.getStore(STORE_NAME);
098        Map<String, Serializable> parameters = store.getParameters(key);
099        if (parameters != null) {
100            AuthorizationRequest authorizationRequest = AuthorizationRequest.fromMap(parameters);
101            return authorizationRequest.isExpired() ? null : authorizationRequest;
102        }
103        return null;
104    }
105
106    public static void remove(String key) {
107        TransientStoreService transientStoreService = Framework.getService(TransientStoreService.class);
108        TransientStore store = transientStoreService.getStore(STORE_NAME);
109        store.remove(key);
110    }
111
112    protected AuthorizationRequest(HttpServletRequest request) {
113        super(request);
114        responseType = request.getParameter(RESPONSE_TYPE_PARAM);
115        scope = request.getParameter(SCOPE_PARAM);
116
117        Principal principal = request.getUserPrincipal();
118        if (principal != null) {
119            username = principal.getName();
120        }
121
122        creationDate = new Date();
123
124        codeChallenge = request.getParameter(CODE_CHALLENGE_PARAM);
125        codeChallengeMethod = request.getParameter(CODE_CHALLENGE_METHOD_PARAM);
126    }
127
128    protected AuthorizationRequest(Map<String, Serializable> map) {
129        clientId = (String) map.get("clientId");
130        redirectURI = (String) map.get("redirectURI");
131        responseType = (String) map.get("responseType");
132        scope = (String) map.get("scope");
133        creationDate = (Date) map.get("creationDate");
134        authorizationCode = (String) map.get("authorizationCode");
135        username = (String) map.get("username");
136        codeChallenge = (String) map.get("codeChallenge");
137        codeChallengeMethod = (String) map.get("codeChallengeMethod");
138    }
139
140    public OAuth2Error checkError() {
141        // Check mandatory fields
142        if (StringUtils.isBlank(clientId)) {
143            return OAuth2Error.invalidRequest(String.format(MISSING_REQUIRED_FIELD_MESSAGE, CLIENT_ID_PARAM));
144        }
145        if (StringUtils.isBlank(responseType)) {
146            return OAuth2Error.invalidRequest(String.format(MISSING_REQUIRED_FIELD_MESSAGE, RESPONSE_TYPE_PARAM));
147        }
148        // Check response type
149        if (!CODE_RESPONSE_TYPE.equals(responseType)) {
150            return OAuth2Error.unsupportedResponseType(String.format("Unknown %s: got \"%s\", expecting \"%s\".",
151                    RESPONSE_TYPE_PARAM, responseType, CODE_RESPONSE_TYPE));
152        }
153
154        // Check if client exists
155        OAuth2ClientService clientService = Framework.getService(OAuth2ClientService.class);
156        OAuth2Client client = clientService.getClient(clientId);
157        if (client == null) {
158            return OAuth2Error.invalidRequest(String.format("Invalid %s: %s.", CLIENT_ID_PARAM, clientId));
159        }
160        if (!client.isEnabled()) {
161            return OAuth2Error.accessDenied(String.format("Client %s is disabled.", clientId));
162        }
163
164        String clientName = client.getName();
165        if (StringUtils.isBlank(clientName)) {
166            log.error(String.format(
167                    "No name set for OAuth2 client %s. It is a required field, please make sure you update this OAuth2 client.",
168                    client));
169            // Here we are just checking that the client has a name since it is now a required field but it might be
170            // empty for an old client.
171            // Yet we don't return an error for backward compatibility since an empty name is not a security issue and
172            // should not prevent the authorization request from working.
173        }
174
175        List<String> clientRedirectURIs = client.getRedirectURIs();
176        if (CollectionUtils.isEmpty(clientRedirectURIs)) {
177            log.error(String.format(
178                    "No redirect URI set for OAuth2 client %s, at least one is required. Please make sure you update this OAuth2 client.",
179                    client));
180            // Checking that the client has at least one redirect URI since it is now a required field but it might be
181            // empty for an old client.
182            // In this case we return an error since we cannot trust the redirect_uri parameter for security reasons.
183            return OAuth2Error.accessDenied("No redirect URI configured for the app.");
184        }
185
186        String clientRedirectURI = null;
187        // No redirect_uri parameter, use the first redirect URI registered for this client
188        if (StringUtils.isBlank(redirectURI)) {
189            clientRedirectURI = clientRedirectURIs.get(0);
190        } else {
191            // Check that the redirect_uri parameter matches one of the the redirect URIs registered for this client
192            if (!clientRedirectURIs.contains(redirectURI)) {
193                return OAuth2Error.invalidRequest(String.format(
194                        "Invalid %s parameter: %s. It must exactly match one of the redirect URIs configured for the app.",
195                        REDIRECT_URI_PARAM, redirectURI));
196            }
197            clientRedirectURI = redirectURI;
198        }
199
200        // Check redirect URI validity
201        if (!OAuth2Client.isRedirectURIValid(clientRedirectURI)) {
202            log.error(String.format(
203                    "The redirect URI %s set for OAuth2 client %s is invalid: it must not be empty and start with https for security reasons. Please make sure you update this OAuth2 client.",
204                    clientRedirectURI, client));
205            return OAuth2Error.invalidRequest(String.format(
206                    "Invalid redirect URI configured for the app: %s. It must not be empty and start with https for security reasons.",
207                    clientRedirectURI));
208        }
209
210        // Check PKCE parameters
211        if (codeChallenge != null && codeChallengeMethod == null
212                || codeChallenge == null && codeChallengeMethod != null) {
213            return OAuth2Error.invalidRequest(String.format(
214                    "Invalid PKCE parameters: either both %s and %s parameters must be sent or none of them.",
215                    CODE_CHALLENGE_PARAM, CODE_CHALLENGE_METHOD_PARAM));
216        }
217        if (codeChallengeMethod != null && !CODE_CHALLENGE_METHODS_SUPPORTED.contains(codeChallengeMethod)) {
218            return OAuth2Error.invalidRequest(String.format(
219                    "Invalid %s parameter: transform algorithm %s not supported. The server only supports %s.",
220                    CODE_CHALLENGE_METHOD_PARAM, codeChallengeMethod, CODE_CHALLENGE_METHODS_SUPPORTED));
221        }
222
223        return null;
224    }
225
226    public boolean isExpired() {
227        // RFC 4.1.2, Authorization code lifetime is 10
228        return new Date().getTime() - creationDate.getTime() > 10 * 60 * 1000;
229    }
230
231    public String getResponseType() {
232        return responseType;
233    }
234
235    public String getScope() {
236        return scope;
237    }
238
239    public String getUsername() {
240        return username;
241    }
242
243    public String getAuthorizationCode() {
244        if (StringUtils.isBlank(authorizationCode)) {
245            authorizationCode = RandomStringUtils.random(10, true, true);
246        }
247        return authorizationCode;
248    }
249
250    public String getCodeChallenge() {
251        return codeChallenge;
252    }
253
254    public String getCodeChallengeMethod() {
255        return codeChallengeMethod;
256    }
257
258    public Map<String, Serializable> toMap() {
259        Map<String, Serializable> map = new HashMap<>();
260        if (clientId != null) {
261            map.put("clientId", clientId);
262        }
263        if (redirectURI != null) {
264            map.put("redirectURI", redirectURI);
265        }
266        if (responseType != null) {
267            map.put("responseType", responseType);
268        }
269        if (scope != null) {
270            map.put("scope", scope);
271        }
272        if (creationDate != null) {
273            map.put("creationDate", creationDate);
274        }
275        if (authorizationCode != null) {
276            map.put("authorizationCode", authorizationCode);
277        }
278        if (username != null) {
279            map.put("username", username);
280        }
281        if (codeChallenge != null) {
282            map.put("codeChallenge", codeChallenge);
283        }
284        if (codeChallengeMethod != null) {
285            map.put("codeChallengeMethod", codeChallengeMethod);
286        }
287        return map;
288    }
289
290    public boolean isCodeVerifierValid(String codeVerifier) {
291        if (codeChallenge == null || codeChallengeMethod == null) {
292            return false;
293        }
294        switch (codeChallengeMethod) {
295        case CODE_CHALLENGE_METHOD_S256:
296            return codeChallenge.equals(Base64.encodeBase64URLSafeString(DigestUtils.sha256(codeVerifier)));
297        case CODE_CHALLENGE_METHOD_PLAIN:
298            return codeChallenge.equals(codeVerifier);
299        default:
300            return false;
301        }
302    }
303
304}