This project has retired. For details please refer to its Attic page.
OAuthAuthenticationProvider xref
View Javadoc

1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   * http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.chemistry.opencmis.client.bindings.spi;
20  
21  import java.io.IOException;
22  import java.io.InputStream;
23  import java.io.InputStreamReader;
24  import java.io.OutputStreamWriter;
25  import java.io.Reader;
26  import java.io.Serializable;
27  import java.io.Writer;
28  import java.net.HttpURLConnection;
29  import java.net.URL;
30  import java.util.ArrayList;
31  import java.util.Collections;
32  import java.util.HashMap;
33  import java.util.List;
34  import java.util.Locale;
35  import java.util.Map;
36  import java.util.concurrent.locks.ReentrantReadWriteLock;
37  
38  import org.apache.chemistry.opencmis.client.bindings.impl.ClientVersion;
39  import org.apache.chemistry.opencmis.commons.SessionParameter;
40  import org.apache.chemistry.opencmis.commons.exceptions.CmisConnectionException;
41  import org.apache.chemistry.opencmis.commons.impl.IOUtils;
42  import org.apache.chemistry.opencmis.commons.impl.MimeHelper;
43  import org.apache.chemistry.opencmis.commons.impl.json.JSONObject;
44  import org.apache.chemistry.opencmis.commons.impl.json.parser.JSONParser;
45  import org.slf4j.Logger;
46  import org.slf4j.LoggerFactory;
47  
48  /**
49   * OAuth 2.0 Authentication Provider.
50   * <p>
51   * This authentication provider implements OAuth 2.0 (RFC 6749) Bearer Tokens
52   * (RFC 6750).
53   * <p>
54   * The provider can be either configured with an authorization code or with an
55   * existing bearer token. Token endpoint and client ID are always required. If a
56   * client secret is required depends on the authorization server.
57   * <p>
58   * Configuration with authorization code:
59   * 
60   * <pre>
61   * {@code
62   * SessionFactory factory = ...
63   * 
64   * Map<String, String> parameter = new HashMap<String, String>();
65   * 
66   * parameter.put(SessionParameter.ATOMPUB_URL, "http://localhost/cmis/atom");
67   * parameter.put(SessionParameter.BINDING_TYPE, BindingType.ATOMPUB.value());
68   * parameter.put(SessionParameter.REPOSITORY_ID, "myRepository");
69   * 
70   * parameter.put(SessionParameter.AUTHENTICATION_PROVIDER_CLASS, "org.apache.chemistry.opencmis.client.bindings.spi.OAuthAuthenticationProvider");
71   * 
72   * parameter.put(SessionParameter.OAUTH_TOKEN_ENDPOINT, "https://example.com/auth/oauth/token");
73   * parameter.put(SessionParameter.OAUTH_CLIENT_ID, "s6BhdRkqt3");
74   * parameter.put(SessionParameter.OAUTH_CLIENT_SECRET, "7Fjfp0ZBr1KtDRbnfVdmIw");
75   * 
76   * parameter.put(SessionParameter.OAUTH_CODE, "abc");
77   * 
78   * ...
79   * Session session = factory.createSession(parameter);
80   * }
81   * </pre>
82   * 
83   * <p>
84   * Configuration with existing bearer token:
85   * 
86   * <pre>
87   * {@code
88   * SessionFactory factory = ...
89   * 
90   * Map<String, String> parameter = new HashMap<String, String>();
91   * 
92   * parameter.put(SessionParameter.ATOMPUB_URL, "http://localhost/cmis/atom");
93   * parameter.put(SessionParameter.BINDING_TYPE, BindingType.ATOMPUB.value());
94   * parameter.put(SessionParameter.REPOSITORY_ID, "myRepository");
95   * 
96   * parameter.put(SessionParameter.AUTHENTICATION_PROVIDER_CLASS, "org.apache.chemistry.opencmis.client.bindings.spi.OAuthAuthenticationProvider");
97   *  
98   * parameter.put(SessionParameter.OAUTH_TOKEN_ENDPOINT, "https://example.com/auth/oauth/token");
99   * parameter.put(SessionParameter.OAUTH_CLIENT_ID, "s6BhdRkqt3");
100  * parameter.put(SessionParameter.OAUTH_CLIENT_SECRET, "7Fjfp0ZBr1KtDRbnfVdmIw");
101  * 
102  * parameter.put(SessionParameter.OAUTH_ACCESS_TOKEN, "2YotnFZFEjr1zCsicMWpAA");
103  * parameter.put(SessionParameter.OAUTH_REFRESH_TOKEN, "tGzv3JOkF0XG5Qx2TlKWIA");
104  * parameter.put(SessionParameter.OAUTH_EXPIRATION_TIMESTAMP, "1388237075127");
105  * 
106  * ...
107  * Session session = factory.createSession(parameter);
108  * }
109  * </pre>
110  * 
111  * <p>
112  * Getting tokens at runtime:
113  * 
114  * <pre>
115  * {@code
116  * OAuthAuthenticationProvider authProvider = (OAuthAuthenticationProvider) session.getBinding().getAuthenticationProvider();
117  * 
118  * // get the current token
119  * Token token = authProvider.getToken();
120  * 
121  * // listen for token refreshes
122  * authProvider.addTokenListener(new OAuthAuthenticationProvider.TokenListener() {
123  *     public void tokenRefreshed(Token token) {
124  *         // do something with the new token
125  *     }
126  * });
127  * }
128  * </pre>
129  * 
130  * <p>
131  * OAuth errors can be handled like this:
132  * 
133  * <pre>
134  * {@code
135  * try {
136  *     ...
137  *     // CMIS calls
138  *      ...
139  * } catch (CmisConnectionException connEx) {
140  *     if (connEx.getCause() instanceof CmisOAuthException) {
141  *         CmisOAuthException oauthEx = (CmisOAuthException) connEx.getCause();
142  * 
143  *         if (CmisOAuthException.ERROR_INVALID_GRANT.equals(oauthEx.getError()) ||
144  *             CmisOAuthException.ERROR_INVALID_TOKEN.equals(oauthEx.getError())) {
145  *             // ask the user to authenticate again
146  *         } else {
147  *            // a configuration or server problem
148  *         }
149  *     }
150  * }
151  * }
152  * </pre>
153  */
154 public class OAuthAuthenticationProvider extends StandardAuthenticationProvider {
155 
156     private static final Logger LOG = LoggerFactory.getLogger(OAuthAuthenticationProvider.class);
157     private static final String TOKEN_TYPE_BEARER = "bearer";
158 
159     private static final long serialVersionUID = 1L;
160 
161     private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
162 
163     private Token token = null;
164     private long defaultTokenLifetime = 3600;
165     private List<TokenListener> tokenListeners;
166 
167     @Override
168     public void setSession(BindingSession session) {
169         super.setSession(session);
170 
171         if (token == null) {
172             // get predefined access token
173             String accessToken = null;
174             if (session.get(SessionParameter.OAUTH_ACCESS_TOKEN) instanceof String) {
175                 accessToken = (String) session.get(SessionParameter.OAUTH_ACCESS_TOKEN);
176             }
177 
178             // get predefined refresh token
179             String refreshToken = null;
180             if (session.get(SessionParameter.OAUTH_REFRESH_TOKEN) instanceof String) {
181                 refreshToken = (String) session.get(SessionParameter.OAUTH_REFRESH_TOKEN);
182             }
183 
184             // get predefined expiration timestamp
185             long expirationTimestamp = 0;
186             if (session.get(SessionParameter.OAUTH_EXPIRATION_TIMESTAMP) instanceof String) {
187                 try {
188                     expirationTimestamp = Long.parseLong((String) session
189                             .get(SessionParameter.OAUTH_EXPIRATION_TIMESTAMP));
190                 } catch (NumberFormatException nfe) {
191                     // ignore
192                 }
193             } else if (session.get(SessionParameter.OAUTH_EXPIRATION_TIMESTAMP) instanceof Number) {
194                 expirationTimestamp = ((Number) session.get(SessionParameter.OAUTH_EXPIRATION_TIMESTAMP)).longValue();
195             }
196 
197             // get default token lifetime
198             if (session.get(SessionParameter.OAUTH_DEFAULT_TOKEN_LIFETIME) instanceof String) {
199                 try {
200                     defaultTokenLifetime = Long.parseLong((String) session
201                             .get(SessionParameter.OAUTH_DEFAULT_TOKEN_LIFETIME));
202                 } catch (NumberFormatException nfe) {
203                     // ignore
204                 }
205             } else if (session.get(SessionParameter.OAUTH_DEFAULT_TOKEN_LIFETIME) instanceof Number) {
206                 defaultTokenLifetime = ((Number) session.get(SessionParameter.OAUTH_DEFAULT_TOKEN_LIFETIME))
207                         .longValue();
208             }
209 
210             token = new Token(accessToken, refreshToken, expirationTimestamp);
211             fireTokenListner(token);
212         }
213     }
214 
215     @Override
216     public Map<String, List<String>> getHTTPHeaders(String url) {
217         Map<String, List<String>> headers = super.getHTTPHeaders(url);
218         if (headers == null) {
219             headers = new HashMap<String, List<String>>();
220         }
221 
222         headers.put("Authorization", Collections.singletonList("Bearer " + getAccessToken()));
223 
224         return headers;
225     }
226 
227     /**
228      * Returns the current token.
229      * 
230      * @return the current token
231      */
232     public Token getToken() {
233         lock.readLock().lock();
234         try {
235             return token;
236         } finally {
237             lock.readLock().unlock();
238         }
239     }
240 
241     /**
242      * Adds a token listener.
243      * 
244      * @param listner
245      *            the listener object
246      */
247     public void addTokenListener(TokenListener listner) {
248         if (listner == null) {
249             return;
250         }
251 
252         lock.writeLock().lock();
253         try {
254             if (tokenListeners == null) {
255                 tokenListeners = new ArrayList<OAuthAuthenticationProvider.TokenListener>();
256             }
257 
258             tokenListeners.add(listner);
259         } finally {
260             lock.writeLock().unlock();
261         }
262     }
263 
264     /**
265      * Removes a token listener.
266      * 
267      * @param listner
268      *            the listener object
269      */
270     public void removeTokenListener(TokenListener listner) {
271         if (listner == null) {
272             return;
273         }
274 
275         lock.writeLock().lock();
276         try {
277             if (tokenListeners != null) {
278                 tokenListeners.remove(listner);
279             }
280         } finally {
281             lock.writeLock().unlock();
282         }
283     }
284 
285     /**
286      * Lets all token listeners know that there is a new token.
287      */
288     protected void fireTokenListner(Token token) {
289         if (tokenListeners == null) {
290             return;
291         }
292 
293         for (TokenListener listner : tokenListeners) {
294             listner.tokenRefreshed(token);
295         }
296     }
297 
298     @Override
299     protected boolean getSendBearerToken() {
300         // the super class should not handle bearer tokens
301         return false;
302     }
303 
304     /**
305      * Gets the access token. If no access token is present or the access token
306      * is expired, a new token is requested.
307      * 
308      * @return the access token
309      */
310     protected String getAccessToken() {
311         lock.writeLock().lock();
312         try {
313             if (token.getAccessToken() == null) {
314                 if (token.getRefreshToken() == null) {
315                     requestToken();
316                 } else {
317                     refreshToken();
318                 }
319             } else if (token.isExpired()) {
320                 refreshToken();
321             }
322 
323             return token.getAccessToken();
324         } catch (CmisConnectionException ce) {
325             throw ce;
326         } catch (Exception e) {
327             throw new CmisConnectionException("Cannot get OAuth access token: " + e.getMessage(), e);
328         } finally {
329             lock.writeLock().unlock();
330         }
331     }
332 
333     private void requestToken() throws IOException {
334         if (LOG.isDebugEnabled()) {
335             LOG.debug("Requesting new OAuth access token.");
336         }
337 
338         makeRequest(false);
339 
340         if (LOG.isTraceEnabled()) {
341             LOG.trace(token.toString());
342         }
343     }
344 
345     private void refreshToken() throws IOException {
346         if (LOG.isDebugEnabled()) {
347             LOG.debug("Refreshing OAuth access token.");
348         }
349 
350         makeRequest(true);
351 
352         if (LOG.isTraceEnabled()) {
353             LOG.trace(token.toString());
354         }
355     }
356 
357     private void makeRequest(boolean isRefresh) throws IOException {
358         Object tokenEndpoint = getSession().get(SessionParameter.OAUTH_TOKEN_ENDPOINT);
359         if (!(tokenEndpoint instanceof String)) {
360             throw new CmisConnectionException("Token endpoint not set!");
361         }
362 
363         if (isRefresh && token.getRefreshToken() == null) {
364             throw new CmisConnectionException("No refresh token!");
365         }
366 
367         // request token
368         HttpURLConnection conn = (HttpURLConnection) (new URL(tokenEndpoint.toString())).openConnection();
369         conn.setRequestMethod("POST");
370         conn.setDoInput(true);
371         conn.setDoOutput(true);
372         conn.setAllowUserInteraction(false);
373         conn.setUseCaches(false);
374         conn.setRequestProperty("User-Agent",
375                 (String) getSession().get(SessionParameter.USER_AGENT, ClientVersion.OPENCMIS_USER_AGENT));
376         conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
377 
378         // compile request
379         Writer writer = new OutputStreamWriter(conn.getOutputStream(), IOUtils.UTF8);
380 
381         if (isRefresh) {
382             writer.write("grant_type=refresh_token");
383 
384             writer.write("&refresh_token=");
385             writer.write(IOUtils.encodeURL(token.getRefreshToken()));
386         } else {
387             writer.write("grant_type=authorization_code");
388 
389             Object code = getSession().get(SessionParameter.OAUTH_CODE);
390             if (code != null) {
391                 writer.write("&code=");
392                 writer.write(IOUtils.encodeURL(code.toString()));
393             }
394 
395             Object redirectUri = getSession().get(SessionParameter.OAUTH_REDIRECT_URI);
396             if (redirectUri != null) {
397                 writer.write("&redirect_uri=");
398                 writer.write(IOUtils.encodeURL(redirectUri.toString()));
399             }
400         }
401 
402         Object clientId = getSession().get(SessionParameter.OAUTH_CLIENT_ID);
403         if (clientId != null) {
404             writer.write("&client_id=");
405             writer.write(IOUtils.encodeURL(clientId.toString()));
406         }
407 
408         Object clientSecret = getSession().get(SessionParameter.OAUTH_CLIENT_SECRET);
409         if (clientSecret != null) {
410             writer.write("&client_secret=");
411             writer.write(IOUtils.encodeURL(clientSecret.toString()));
412         }
413 
414         writer.close();
415 
416         // connect
417         conn.connect();
418 
419         // check success
420         if (conn.getResponseCode() != 200) {
421             JSONObject jsonResponse = parseResponse(conn);
422 
423             Object error = jsonResponse.get("error");
424             String errorStr = error == null ? null : error.toString();
425 
426             Object description = jsonResponse.get("error_description");
427             String descriptionStr = description == null ? null : description.toString();
428 
429             Object uri = jsonResponse.get("error_uri");
430             String uriStr = uri == null ? null : uri.toString();
431 
432             if (LOG.isDebugEnabled()) {
433                 LOG.debug("OAuth token request failed: {}", jsonResponse.toJSONString());
434             }
435 
436             throw new CmisOAuthException("OAuth token request failed" + (errorStr == null ? "" : ": " + errorStr)
437                     + (descriptionStr == null ? "" : ": " + descriptionStr), errorStr, descriptionStr, uriStr);
438         }
439 
440         // parse response
441         JSONObject jsonResponse = parseResponse(conn);
442 
443         Object tokenType = jsonResponse.get("token_type");
444         if (!(tokenType instanceof String) || !TOKEN_TYPE_BEARER.equalsIgnoreCase((String) tokenType)) {
445             throw new CmisOAuthException("Unsupported OAuth token type: " + tokenType);
446         }
447 
448         Object jsonAccessToken = jsonResponse.get("access_token");
449         if (!(jsonAccessToken instanceof String)) {
450             throw new CmisOAuthException("Invalid OAuth access_token!");
451         }
452 
453         Object jsonRefreshToken = jsonResponse.get("refresh_token");
454         if (jsonRefreshToken != null && !(jsonRefreshToken instanceof String)) {
455             throw new CmisOAuthException("Invalid OAuth refresh_token!");
456         }
457 
458         long expiresIn = defaultTokenLifetime;
459         Object jsonExpiresIn = jsonResponse.get("expires_in");
460         if (jsonExpiresIn != null) {
461             if (jsonExpiresIn instanceof Number) {
462                 expiresIn = ((Number) jsonExpiresIn).longValue();
463             } else if (jsonExpiresIn instanceof String) {
464                 try {
465                     expiresIn = Long.parseLong((String) jsonExpiresIn);
466                 } catch (NumberFormatException nfe) {
467                     throw new CmisOAuthException("Invalid OAuth expires_in value!");
468                 }
469             } else {
470                 throw new CmisOAuthException("Invalid OAuth expires_in value!");
471             }
472 
473             if (expiresIn <= 0) {
474                 expiresIn = defaultTokenLifetime;
475             }
476         }
477 
478         token = new Token(jsonAccessToken.toString(), (jsonRefreshToken == null ? null : jsonRefreshToken.toString()),
479                 expiresIn * 1000 + System.currentTimeMillis());
480         fireTokenListner(token);
481     }
482 
483     private JSONObject parseResponse(HttpURLConnection conn) {
484         Reader reader = null;
485         InputStream stream = null;
486         try {
487             int respCode = conn.getResponseCode();
488             if (respCode == 401) {
489                 Map<String, Map<String, String>> challenges = MimeHelper.getChallengesFromAuthenticateHeader(conn
490                         .getHeaderField("WWW-Authenticate"));
491 
492                 if (challenges != null && challenges.containsKey(TOKEN_TYPE_BEARER)) {
493                     Map<String, String> params = challenges.get(TOKEN_TYPE_BEARER);
494 
495                     String errorStr = params.get("error");
496                     String descriptionStr = params.get("error_description");
497                     String uriStr = params.get("error_uri");
498 
499                     if (LOG.isDebugEnabled()) {
500                         LOG.debug("Invalid OAuth token: {}", params.toString());
501                     }
502 
503                     throw new CmisOAuthException("Unauthorized" + (errorStr == null ? "" : ": " + errorStr)
504                             + (descriptionStr == null ? "" : ": " + descriptionStr), errorStr, descriptionStr, uriStr);
505                 }
506 
507                 throw new CmisOAuthException("Unauthorized!");
508             }
509 
510             if (respCode >= 200 && respCode < 300) {
511                 stream = conn.getInputStream();
512             } else {
513                 stream = conn.getErrorStream();
514             }
515             if (stream == null) {
516                 throw new CmisOAuthException("Invalid OAuth token response!");
517             }
518 
519             reader = new InputStreamReader(stream, extractCharset(conn));
520             JSONParser parser = new JSONParser();
521             Object response = parser.parse(reader);
522 
523             if (!(response instanceof JSONObject)) {
524                 throw new CmisOAuthException("Invalid OAuth token response!");
525             }
526 
527             return (JSONObject) response;
528         } catch (CmisConnectionException ce) {
529             throw ce;
530         } catch (Exception pe) {
531             throw new CmisOAuthException("Parsing the OAuth token response failed: " + pe.getMessage(), pe);
532         } finally {
533             IOUtils.consumeAndClose(reader);
534             if (reader == null) {
535                 IOUtils.closeQuietly(stream);
536             }
537         }
538     }
539 
540     private String extractCharset(HttpURLConnection conn) {
541         String charset = IOUtils.UTF8;
542 
543         String contentType = conn.getContentType();
544         if (contentType != null) {
545             String[] parts = contentType.split(";");
546             for (int i = 1; i < parts.length; i++) {
547                 String part = parts[i].trim().toLowerCase(Locale.ENGLISH);
548                 if (part.startsWith("charset")) {
549                     int x = part.indexOf('=');
550                     charset = part.substring(x + 1).trim();
551                     break;
552                 }
553             }
554         }
555 
556         return charset;
557     }
558 
559     /**
560      * Token holder class.
561      */
562     public static class Token implements Serializable {
563 
564         private static final long serialVersionUID = 1L;
565 
566         private String accessToken;
567         private String refreshToken;
568         private long expirationTimestamp;
569 
570         public Token(String accessToken, String refreshToken, long expirationTimestamp) {
571             this.accessToken = accessToken;
572             this.refreshToken = refreshToken;
573             this.expirationTimestamp = expirationTimestamp;
574         }
575 
576         /**
577          * Returns the access token.
578          * 
579          * @return the access token
580          */
581         public String getAccessToken() {
582             return accessToken;
583         }
584 
585         /**
586          * Returns the refresh token.
587          * 
588          * @return the refresh token
589          */
590         public String getRefreshToken() {
591             return refreshToken;
592         }
593 
594         /**
595          * Returns the timestamp when the access expires.
596          * 
597          * @return the timestamp in milliseconds since midnight, January 1, 1970
598          *         UTC.
599          */
600         public long getExpirationTimestamp() {
601             return expirationTimestamp;
602         }
603 
604         /**
605          * Returns whether the access token is expired or not.
606          * 
607          * @return {@code true} if the access token is expired, {@code false}
608          *         otherwise
609          */
610         public boolean isExpired() {
611             return System.currentTimeMillis() >= expirationTimestamp;
612         }
613 
614         @Override
615         public String toString() {
616             return "Access token: " + accessToken + " / Refresh token: " + refreshToken + " / Expires : "
617                     + expirationTimestamp;
618         }
619     }
620 
621     /**
622      * Listener for OAuth token events.
623      */
624     public interface TokenListener {
625 
626         /**
627          * Called when a token is requested of refreshed.
628          * 
629          * @param token
630          *            the new token
631          */
632         void tokenRefreshed(Token token);
633     }
634 
635     /**
636      * Exception for OAuth errors.
637      */
638     public static class CmisOAuthException extends CmisConnectionException {
639 
640         private static final long serialVersionUID = 1L;
641 
642         // general OAuth errors
643         public static final String ERROR_INVALID_REQUEST = "invalid_request";
644         public static final String ERROR_INVALID_CLIENT = "invalid_client";
645         public static final String ERROR_INVALID_GRANT = "invalid_grant";
646         public static final String ERROR_UNAUTHORIZED_CLIENT = "unauthorized_client";
647         public static final String ERROR_UNSUPPORTED_GRANT_TYPE = "unsupported_grant_type";
648         public static final String ERROR_INVALID_SCOPE = "invalid_scope";
649 
650         // bearer specific
651         public static final String ERROR_INVALID_TOKEN = "invalid_token";
652 
653         private String error;
654         private String errorDescription;
655         private String errorUri;
656 
657         public CmisOAuthException() {
658             super();
659         }
660 
661         public CmisOAuthException(String message) {
662             super(message);
663         }
664 
665         public CmisOAuthException(String message, Throwable cause) {
666             super(message, cause);
667         }
668 
669         public CmisOAuthException(String message, String error, String errorDescription, String errorUri) {
670             super(message);
671             this.error = error;
672             this.errorDescription = errorDescription;
673             this.errorUri = errorUri;
674         }
675 
676         public String getError() {
677             return error;
678         }
679 
680         public String getErrorDescription() {
681             return errorDescription;
682         }
683 
684         public String getErrorUri() {
685             return errorUri;
686         }
687     }
688 }