Java tutorial
/* * (C) Copyright 2010 Nuxeo SAS (http://nuxeo.com/) and contributors. * * All rights reserved. This program and the accompanying materials * are made available under the terms of the GNU Lesser General Public License * (LGPL) version 2.1 which accompanies this distribution, and is available at * http://www.gnu.org/licenses/lgpl.html * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * Contributors: * Nuxeo - initial API and implementation */ package org.nuxeo.opensocial.shindig.oauth; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import java.util.regex.Pattern; import net.oauth.OAuth; import net.oauth.OAuth.Parameter; import net.oauth.OAuthAccessor; import net.oauth.OAuthException; import net.oauth.OAuthMessage; import net.oauth.OAuthProblemException; import org.apache.commons.codec.binary.Base64; import org.apache.commons.codec.digest.DigestUtils; import org.apache.commons.io.IOUtils; import org.apache.commons.lang.StringUtils; import org.apache.shindig.auth.OAuthConstants; import org.apache.shindig.auth.OAuthUtil; import org.apache.shindig.common.uri.Uri; import org.apache.shindig.common.uri.UriBuilder; import org.apache.shindig.common.util.CharsetUtil; import org.apache.shindig.gadgets.GadgetException; import org.apache.shindig.gadgets.http.HttpFetcher; import org.apache.shindig.gadgets.http.HttpRequest; import org.apache.shindig.gadgets.http.HttpResponse; import org.apache.shindig.gadgets.http.HttpResponseBuilder; import org.apache.shindig.gadgets.oauth.AccessorInfo; import org.apache.shindig.gadgets.oauth.AccessorInfo.HttpMethod; import org.apache.shindig.gadgets.oauth.AccessorInfo.OAuthParamLocation; import org.apache.shindig.gadgets.oauth.OAuthArguments; import org.apache.shindig.gadgets.oauth.OAuthClientState; import org.apache.shindig.gadgets.oauth.OAuthError; import org.apache.shindig.gadgets.oauth.OAuthFetcherConfig; import org.apache.shindig.gadgets.oauth.OAuthRequest; import org.apache.shindig.gadgets.oauth.OAuthResponseParams; import org.apache.shindig.gadgets.oauth.OAuthResponseParams.OAuthRequestException; import org.apache.shindig.gadgets.oauth.OAuthStore.TokenInfo; import org.json.JSONObject; import org.nuxeo.opensocial.service.api.OpenSocialService; import org.nuxeo.runtime.api.Framework; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Lists; import com.google.common.collect.Maps; /*** * This is complete crap. I end up copying the class because the idiots made all * the implementation classes private for no reason. * * Look for the string "NUXEO NUXEO NUXEO CHANGE" to see my changes. * * irritated @author Ian Smith<ismith@nuxeo.com> * */ public class NuxeoOAuthRequest extends OAuthRequest { public static final String NUXEO_INTERNAL_REQUEST = "Nuxeo-Internal-Request"; // Maximum number of attempts at the protocol before giving up. private static final int MAX_ATTEMPTS = 2; // names of additional OAuth parameters we include in outgoing requests // TODO(beaton): can we do away with this bit in favor of the opensocial // param? public static final String XOAUTH_APP_URL = "xoauth_app_url"; protected static final String OPENSOCIAL_OWNERID = "opensocial_owner_id"; protected static final String OPENSOCIAL_VIEWERID = "opensocial_viewer_id"; protected static final String OPENSOCIAL_APPID = "opensocial_app_id"; // TODO(beaton): figure out if this is the name in the 0.8 spec. protected static final String OPENSOCIAL_APPURL = "opensocial_app_url"; protected static final String OPENSOCIAL_PROXIED_CONTENT = "opensocial_proxied_content"; // old and new parameters for the public key // TODO remove OLD in a far future release protected static final String XOAUTH_PUBLIC_KEY_OLD = "xoauth_signature_publickey"; protected static final String XOAUTH_PUBLIC_KEY_NEW = "xoauth_public_key"; protected static final Pattern ALLOWED_PARAM_NAME = Pattern.compile("[-:\\w~!@$*()_\\[\\]:,./]+"); private static final long ACCESS_TOKEN_EXPIRE_UNKNOWN = 0; private static final long ACCESS_TOKEN_FORCE_EXPIRE = -1; /** * Configuration options for the fetcher. */ protected final OAuthFetcherConfig fetcherConfig; /** * Next fetcher to use in chain. */ private final HttpFetcher fetcher; /** * Additional trusted parameters to be included in the OAuth request. */ private final List<Parameter> trustedParams; /** * State information from client */ protected OAuthClientState clientState; /** * OAuth specific stuff to include in the response. */ protected OAuthResponseParams responseParams; /** * The accessor we use for signing messages. This also holds metadata about * the service provider, such as their URLs and the keys we use to access * those URLs. */ private AccessorInfo accessorInfo; /** * The request the client really wants to make. */ private HttpRequest realRequest; /** * Data returned along with OAuth access token, null if this is not an * access token request */ private Map<String, String> accessTokenData; /** * @param fetcherConfig configuration options for the fetcher * @param fetcher fetcher to use for actually making requests */ public NuxeoOAuthRequest(OAuthFetcherConfig fetcherConfig, HttpFetcher fetcher) { this(fetcherConfig, fetcher, null); } /** * @param fetcherConfig configuration options for the fetcher * @param fetcher fetcher to use for actually making requests * @param trustedParams additional parameters to include in all outgoing * OAuth requests, useful for client data that can't be pulled * from the security token but is still trustworthy. */ public NuxeoOAuthRequest(OAuthFetcherConfig fetcherConfig, HttpFetcher fetcher, List<Parameter> trustedParams) { super(fetcherConfig, fetcher, trustedParams); this.fetcherConfig = fetcherConfig; this.fetcher = fetcher; this.trustedParams = trustedParams; } /** * OAuth authenticated fetch. */ @Override public HttpResponse fetch(HttpRequest request) { realRequest = request; clientState = new OAuthClientState(fetcherConfig.getStateCrypter(), request.getOAuthArguments().getOrigClientState()); responseParams = new OAuthResponseParams(request.getSecurityToken(), request, fetcherConfig.getStateCrypter()); try { return fetchNoThrow(); } catch (RuntimeException e) { // We log here to record the request/response pairs that created the // failure. responseParams.logDetailedWarning("OAuth fetch unexpected fatal error", e); throw e; } } protected boolean isInternalRequest() { String requestedURI = realRequest.getUri().toString(); OpenSocialService os = Framework.getLocalService(OpenSocialService.class); for (String trustedHost : os.getTrustedHosts()) { if (requestedURI.startsWith("http://" + trustedHost)) { return true; } } return false; } /** * Fetch data and build a response to return to the client. We try to always * return something reasonable to the calling app no matter what kind of * madness happens along the way. If an unchecked exception occurs, well, * then the client is out of luck. */ private HttpResponse fetchNoThrow() { HttpResponseBuilder response = null; try { OAuthArguments oauthArgs = realRequest.getOAuthArguments(); if (isInternalRequest()) { oauthArgs.setRequestOption(NUXEO_INTERNAL_REQUEST, "true"); } accessorInfo = fetcherConfig.getTokenStore().getOAuthAccessor(realRequest.getSecurityToken(), oauthArgs, clientState, responseParams, fetcherConfig); response = fetchWithRetry(); } catch (OAuthRequestException e) { // No data for us. if (OAuthError.UNAUTHENTICATED.toString().equals(responseParams.getError())) { responseParams.logDetailedInfo("Unauthenticated OAuth fetch", e); } else { responseParams.logDetailedWarning("OAuth fetch fatal error", e); } responseParams.setSendTraceToClient(true); response = new HttpResponseBuilder().setHttpStatusCode(HttpResponse.SC_FORBIDDEN).setStrictNoCache(); responseParams.addToResponse(response); return response.create(); } // OK, got some data back, annotate it as necessary. if (response.getHttpStatusCode() >= 400) { responseParams.logDetailedWarning("OAuth fetch fatal error"); responseParams.setSendTraceToClient(true); } else if (responseParams.getAznUrl() != null && responseParams.sawErrorResponse()) { responseParams.logDetailedWarning("OAuth fetch error, reprompting for user approval"); responseParams.setSendTraceToClient(true); } responseParams.addToResponse(response); return response.create(); } /** * Fetch data, retrying in the event that that the service provider returns * an error and we think we can recover by restarting the protocol flow. */ private HttpResponseBuilder fetchWithRetry() throws OAuthRequestException { int attempts = 0; boolean retry; HttpResponseBuilder response = null; do { retry = false; ++attempts; try { response = attemptFetch(); } catch (NXOAuthProtocolException pe) { retry = handleProtocolException(pe, attempts); if (!retry) { if (pe.getProblemCode() != null) { throw responseParams.oauthRequestException(pe.getProblemCode(), "Service provider rejected request", pe); } else { throw responseParams.oauthRequestException(OAuthError.UNKNOWN_PROBLEM, "Service provider rejected request", pe); } } } } while (retry); return response; } private boolean handleProtocolException(NXOAuthProtocolException pe, int attempts) throws OAuthRequestException { if (pe.canExtend()) { accessorInfo.setTokenExpireMillis(ACCESS_TOKEN_FORCE_EXPIRE); } else if (pe.startFromScratch()) { fetcherConfig.getTokenStore().removeToken(realRequest.getSecurityToken(), accessorInfo.getConsumer(), realRequest.getOAuthArguments(), responseParams); accessorInfo.getAccessor().accessToken = null; accessorInfo.getAccessor().requestToken = null; accessorInfo.getAccessor().tokenSecret = null; accessorInfo.setSessionHandle(null); accessorInfo.setTokenExpireMillis(ACCESS_TOKEN_EXPIRE_UNKNOWN); } return (attempts < MAX_ATTEMPTS && pe.canRetry()); } /** * Does one of the following: 1) Sends a request token request, and returns * an approval URL to the calling app. 2) Sends an access token request to * swap a request token for an access token, and then asks for data from the * service provider. 3) Asks for data from the service provider. */ private HttpResponseBuilder attemptFetch() throws NXOAuthProtocolException, OAuthResponseParams.OAuthRequestException { if (needApproval()) { // This is section 6.1 of the OAuth spec. checkCanApprove(); fetchRequestToken(); // This is section 6.2 of the OAuth spec. buildClientApprovalState(); buildAznUrl(); // break out of the content fetching chain, we need permission from // the user to do this return new HttpResponseBuilder().setHttpStatusCode(HttpResponse.SC_OK).setStrictNoCache(); } else if (needAccessToken()) { // This is section 6.3 of the OAuth spec checkCanApprove(); exchangeRequestToken(); saveAccessToken(); buildClientAccessState(); } return fetchData(); } /** * Do we need to get the user's approval to access the data? */ private boolean needApproval() { return (realRequest.getOAuthArguments().mustUseToken() && accessorInfo.getAccessor().requestToken == null && accessorInfo.getAccessor().accessToken == null); } /** * Make sure the user is authorized to approve access tokens. At the moment * we restrict this to page owner's viewing their own pages. */ private void checkCanApprove() throws OAuthResponseParams.OAuthRequestException { String pageOwner = realRequest.getSecurityToken().getOwnerId(); String pageViewer = realRequest.getSecurityToken().getViewerId(); String stateOwner = clientState.getOwner(); if (pageOwner == null || pageViewer == null) { throw responseParams.oauthRequestException(OAuthError.UNAUTHENTICATED, "Unauthenticated"); } if (!fetcherConfig.isViewerAccessTokensEnabled() && !pageOwner.equals(pageViewer)) { throw responseParams.oauthRequestException(OAuthError.NOT_OWNER, "Non-Secure Owner Page -- Only page owners can grant OAuth approval"); } if (stateOwner != null && !stateOwner.equals(pageViewer)) { throw responseParams.oauthRequestException(OAuthError.UNKNOWN_PROBLEM, "Client state belongs to a different person " + "(state owner=" + stateOwner + ", pageViewer=" + pageViewer + ')'); } } private void fetchRequestToken() throws OAuthResponseParams.OAuthRequestException, NXOAuthProtocolException { OAuthAccessor accessor = accessorInfo.getAccessor(); HttpRequest request = createRequestTokenRequest(accessor); List<Parameter> requestTokenParams = Lists.newArrayList(); addCallback(requestTokenParams); HttpRequest signed = sanitizeAndSign(request, requestTokenParams, true); OAuthMessage reply = sendOAuthMessage(signed); accessor.requestToken = OAuthUtil.getParameter(reply, OAuth.OAUTH_TOKEN); accessor.tokenSecret = OAuthUtil.getParameter(reply, OAuth.OAUTH_TOKEN_SECRET); } private HttpRequest createRequestTokenRequest(OAuthAccessor accessor) throws OAuthResponseParams.OAuthRequestException { if (accessor.consumer.serviceProvider.requestTokenURL == null) { throw responseParams.oauthRequestException(OAuthError.BAD_OAUTH_CONFIGURATION, "No request token URL specified"); } HttpRequest request = new HttpRequest(Uri.parse(accessor.consumer.serviceProvider.requestTokenURL)); request.setMethod(accessorInfo.getHttpMethod().toString()); if (accessorInfo.getHttpMethod() == HttpMethod.POST) { request.setHeader("Content-Type", OAuth.FORM_ENCODED); } return request; } private void addCallback(List<Parameter> requestTokenParams) throws OAuthResponseParams.OAuthRequestException { // This will be either the consumer key callback URL or the global // callback URL. String baseCallback = StringUtils.trimToNull(accessorInfo.getConsumer().getCallbackUrl()); if (baseCallback != null) { String callbackUrl = fetcherConfig.getOAuthCallbackGenerator().generateCallback(fetcherConfig, baseCallback, realRequest, responseParams); if (callbackUrl != null) { requestTokenParams.add(new Parameter(OAuth.OAUTH_CALLBACK, callbackUrl)); } } } /** * Strip out any owner or viewer identity information passed by the client. */ private List<Parameter> sanitize(List<Parameter> params) throws OAuthResponseParams.OAuthRequestException { ArrayList<Parameter> list = Lists.newArrayList(); for (Parameter p : params) { String name = p.getKey(); if (allowParam(name)) { list.add(p); } else { throw responseParams.oauthRequestException(OAuthError.INVALID_REQUEST, "invalid parameter name " + name + ", applications may not override opensocial or oauth parameters"); } } return list; } private boolean allowParam(String paramName) { String canonParamName = paramName.toLowerCase(); return (!(canonParamName.startsWith("oauth") || canonParamName.startsWith("xoauth") || canonParamName.startsWith("opensocial")) && ALLOWED_PARAM_NAME.matcher(canonParamName).matches()); } /** * Add identity information, such as owner/viewer/gadget. */ private void addIdentityParams(List<Parameter> params) { // If no owner or viewer information is required, don't add any identity // params. This lets // us be compatible with strict OAuth service providers that reject // extra parameters on // requests. if (!realRequest.getOAuthArguments().getSignOwner() && !realRequest.getOAuthArguments().getSignViewer()) { return; } String owner = realRequest.getSecurityToken().getOwnerId(); if (owner != null && realRequest.getOAuthArguments().getSignOwner()) { params.add(new Parameter(OPENSOCIAL_OWNERID, owner)); } String viewer = realRequest.getSecurityToken().getViewerId(); if (viewer != null && realRequest.getOAuthArguments().getSignViewer()) { params.add(new Parameter(OPENSOCIAL_VIEWERID, viewer)); } /* * NUXEO NUXEO NUXEO CHANGE! We cannot reliably determine how to compute * this so we just omit it. * * String app = realRequest.getSecurityToken().getAppId(); if (app != * null) { params.add(new Parameter(OPENSOCIAL_APPID, app)); } * * String appUrl = realRequest.getSecurityToken().getAppUrl(); if * (appUrl != null) { params.add(new Parameter(OPENSOCIAL_APPURL, * appUrl)); } */ if (trustedParams != null) { params.addAll(trustedParams); } if (realRequest.getOAuthArguments().isProxiedContentRequest()) { params.add(new Parameter(OPENSOCIAL_PROXIED_CONTENT, "1")); } } /** * Add signature type to the message. */ private void addSignatureParams(List<Parameter> params) { if (accessorInfo.getConsumer().getConsumer().consumerKey == null) { params.add(new Parameter(OAuth.OAUTH_CONSUMER_KEY, realRequest.getSecurityToken().getDomain())); } if (accessorInfo.getConsumer().getKeyName() != null) { params.add(new Parameter(XOAUTH_PUBLIC_KEY_OLD, accessorInfo.getConsumer().getKeyName())); params.add(new Parameter(XOAUTH_PUBLIC_KEY_NEW, accessorInfo.getConsumer().getKeyName())); } params.add(new Parameter(OAuth.OAUTH_VERSION, OAuth.VERSION_1_0)); params.add(new Parameter(OAuth.OAUTH_TIMESTAMP, Long.toString(fetcherConfig.getClock().currentTimeMillis() / 1000))); } static String getAuthorizationHeader(List<Map.Entry<String, String>> oauthParams) { StringBuilder result = new StringBuilder("OAuth "); boolean first = true; for (Map.Entry<String, String> parameter : oauthParams) { if (!first) { result.append(", "); } else { first = false; } result.append(OAuth.percentEncode(parameter.getKey())).append("=\"") .append(OAuth.percentEncode(parameter.getValue())).append('"'); } return result.toString(); } /** * Start with an HttpRequest. Throw if there are any attacks in the query. * Throw if there are any attacks in the post body. Build up OAuth parameter * list. Sign it. Add OAuth parameters to new request. Send it. */ @Override public HttpRequest sanitizeAndSign(HttpRequest base, List<Parameter> params, boolean tokenEndpoint) throws OAuthResponseParams.OAuthRequestException { if (params == null) { params = Lists.newArrayList(); } UriBuilder target = new UriBuilder(base.getUri()); String query = target.getQuery(); target.setQuery(null); params.addAll(sanitize(OAuth.decodeForm(query))); switch (OAuthUtil.getSignatureType(tokenEndpoint, base.getHeader("Content-Type"))) { case URL_ONLY: break; case URL_AND_FORM_PARAMS: params.addAll(sanitize(OAuth.decodeForm(base.getPostBodyAsString()))); break; case URL_AND_BODY_HASH: try { byte[] body = IOUtils.toByteArray(base.getPostBody()); byte[] hash = DigestUtils.sha(body); String b64 = new String(Base64.encodeBase64(hash), CharsetUtil.UTF8.name()); params.add(new Parameter(OAuthConstants.OAUTH_BODY_HASH, b64)); } catch (IOException e) { throw responseParams.oauthRequestException(OAuthError.UNKNOWN_PROBLEM, "Error taking body hash", e); } break; } addIdentityParams(params); addSignatureParams(params); try { OAuthMessage signed = OAuthUtil.newRequestMessage(accessorInfo.getAccessor(), base.getMethod(), target.toString(), params); HttpRequest oauthHttpRequest = createHttpRequest(base, selectOAuthParams(signed)); // Following 302s on OAuth responses is unlikely to be productive. oauthHttpRequest.setFollowRedirects(false); return oauthHttpRequest; } catch (OAuthException e) { throw responseParams.oauthRequestException(OAuthError.UNKNOWN_PROBLEM, "Error signing message", e); } } private HttpRequest createHttpRequest(HttpRequest base, List<Map.Entry<String, String>> oauthParams) throws OAuthResponseParams.OAuthRequestException { OAuthParamLocation paramLocation = accessorInfo.getParamLocation(); // paramLocation could be overriden by a run-time parameter to // fetchRequest HttpRequest result = new HttpRequest(base); // If someone specifies that OAuth parameters go in the body, but then // sends a request for // data using GET, we've got a choice. We can throw some type of error, // since a GET request // can't have a body, or we can stick the parameters somewhere else, // like, say, the header. // We opt to put them in the header, since that stands some chance of // working with some // OAuth service providers. if (paramLocation == OAuthParamLocation.POST_BODY && !result.getMethod().equals("POST")) { paramLocation = OAuthParamLocation.AUTH_HEADER; } switch (paramLocation) { case AUTH_HEADER: result.addHeader("Authorization", getAuthorizationHeader(oauthParams)); break; case POST_BODY: String contentType = result.getHeader("Content-Type"); if (!OAuth.isFormEncoded(contentType)) { throw responseParams.oauthRequestException(OAuthError.INVALID_REQUEST, "OAuth param location can only be post_body if post body if of " + "type x-www-form-urlencoded"); } String oauthData = OAuthUtil.formEncode(oauthParams); if (result.getPostBodyLength() == 0) { result.setPostBody(CharsetUtil.getUtf8Bytes(oauthData)); } else { result.setPostBody((result.getPostBodyAsString() + '&' + oauthData).getBytes()); } break; case URI_QUERY: result.setUri(Uri.parse(OAuthUtil.addParameters(result.getUri().toString(), oauthParams))); break; } return result; } /** * Sends OAuth request token and access token messages. */ private OAuthMessage sendOAuthMessage(HttpRequest request) throws OAuthResponseParams.OAuthRequestException, NXOAuthProtocolException { HttpResponse response = fetchFromServer(request); checkForProtocolProblem(response); OAuthMessage reply = new OAuthMessage(null, null, null); reply.addParameters(OAuth.decodeForm(response.getResponseAsString())); reply = parseAuthHeader(reply, response); if (OAuthUtil.getParameter(reply, OAuth.OAUTH_TOKEN) == null) { throw responseParams.oauthRequestException(OAuthError.UNKNOWN_PROBLEM, "No oauth_token returned from service provider"); } if (OAuthUtil.getParameter(reply, OAuth.OAUTH_TOKEN_SECRET) == null) { throw responseParams.oauthRequestException(OAuthError.UNKNOWN_PROBLEM, "No oauth_token_secret returned from service provider"); } return reply; } /** * Parse OAuth WWW-Authenticate header and either add them to an existing * message or create a new message. * * @param msg * @param resp * @return the updated message. */ private OAuthMessage parseAuthHeader(OAuthMessage msg, HttpResponse resp) { if (msg == null) { msg = new OAuthMessage(null, null, null); } for (String auth : resp.getHeaders("WWW-Authenticate")) { msg.addParameters(OAuthMessage.decodeAuthorization(auth)); } return msg; } /** * Builds the data we'll cache on the client while we wait for approval. */ private void buildClientApprovalState() { OAuthAccessor accessor = accessorInfo.getAccessor(); responseParams.getNewClientState().setRequestToken(accessor.requestToken); responseParams.getNewClientState().setRequestTokenSecret(accessor.tokenSecret); responseParams.getNewClientState().setOwner(realRequest.getSecurityToken().getOwnerId()); } /** * Builds the URL the client needs to visit to approve access. */ private void buildAznUrl() throws OAuthResponseParams.OAuthRequestException { // We add the token, gadget is responsible for the callback URL. OAuthAccessor accessor = accessorInfo.getAccessor(); if (accessor.consumer.serviceProvider.userAuthorizationURL == null) { throw responseParams.oauthRequestException(OAuthError.BAD_OAUTH_CONFIGURATION, "No authorization URL specified"); } StringBuilder azn = new StringBuilder(accessor.consumer.serviceProvider.userAuthorizationURL); if (azn.indexOf("?") == -1) { azn.append('?'); } else { azn.append('&'); } azn.append(OAuth.OAUTH_TOKEN); azn.append('='); azn.append(OAuth.percentEncode(accessor.requestToken)); responseParams.setAznUrl(azn.toString()); } /** * Do we need to exchange a request token for an access token? */ private boolean needAccessToken() { if (realRequest.getOAuthArguments().mustUseToken() && accessorInfo.getAccessor().requestToken != null && accessorInfo.getAccessor().accessToken == null) { return true; } return realRequest.getOAuthArguments().mayUseToken() && accessTokenExpired(); } private boolean accessTokenExpired() { return (accessorInfo.getTokenExpireMillis() != ACCESS_TOKEN_EXPIRE_UNKNOWN && accessorInfo.getTokenExpireMillis() < fetcherConfig.getClock().currentTimeMillis()); } /** * Implements section 6.3 of the OAuth spec. */ private void exchangeRequestToken() throws OAuthResponseParams.OAuthRequestException, NXOAuthProtocolException { if (accessorInfo.getAccessor().accessToken != null) { // session extension per // http://oauth.googlecode.com/svn/spec/ext/session/1.0/drafts/1/spec.html accessorInfo.getAccessor().requestToken = accessorInfo.getAccessor().accessToken; accessorInfo.getAccessor().accessToken = null; } OAuthAccessor accessor = accessorInfo.getAccessor(); if (accessor.consumer.serviceProvider.accessTokenURL == null) { throw responseParams.oauthRequestException(OAuthError.BAD_OAUTH_CONFIGURATION, "No access token URL specified."); } Uri accessTokenUri = Uri.parse(accessor.consumer.serviceProvider.accessTokenURL); HttpRequest request = new HttpRequest(accessTokenUri); request.setMethod(accessorInfo.getHttpMethod().toString()); if (accessorInfo.getHttpMethod() == HttpMethod.POST) { request.setHeader("Content-Type", OAuth.FORM_ENCODED); } List<Parameter> msgParams = Lists.newArrayList(); msgParams.add(new Parameter(OAuth.OAUTH_TOKEN, accessor.requestToken)); if (accessorInfo.getSessionHandle() != null) { msgParams.add(new Parameter(OAuthConstants.OAUTH_SESSION_HANDLE, accessorInfo.getSessionHandle())); } String receivedCallback = realRequest.getOAuthArguments().getReceivedCallbackUrl(); if (!StringUtils.isBlank(receivedCallback)) { try { Uri parsed = Uri.parse(receivedCallback); String verifier = parsed.getQueryParameter(OAuthConstants.OAUTH_VERIFIER); if (verifier != null) { msgParams.add(new Parameter(OAuthConstants.OAUTH_VERIFIER, verifier)); } } catch (IllegalArgumentException e) { throw responseParams.oauthRequestException(OAuthError.INVALID_REQUEST, "Invalid received callback URL: " + receivedCallback, e); } } HttpRequest signed = sanitizeAndSign(request, msgParams, true); OAuthMessage reply = sendOAuthMessage(signed); accessor.accessToken = OAuthUtil.getParameter(reply, OAuth.OAUTH_TOKEN); accessor.tokenSecret = OAuthUtil.getParameter(reply, OAuth.OAUTH_TOKEN_SECRET); accessorInfo.setSessionHandle(OAuthUtil.getParameter(reply, OAuthConstants.OAUTH_SESSION_HANDLE)); accessorInfo.setTokenExpireMillis(ACCESS_TOKEN_EXPIRE_UNKNOWN); if (OAuthUtil.getParameter(reply, OAuthConstants.OAUTH_EXPIRES_IN) != null) { try { int expireSecs = Integer.parseInt(OAuthUtil.getParameter(reply, OAuthConstants.OAUTH_EXPIRES_IN)); long expireMillis = fetcherConfig.getClock().currentTimeMillis() + expireSecs * 1000; accessorInfo.setTokenExpireMillis(expireMillis); } catch (NumberFormatException e) { // Hrm. Bogus server. We can safely ignore this, we'll just wait // for the server to // tell us when the access token has expired. responseParams.logDetailedWarning("server returned bogus expiration"); } } // Clients may want to retrieve extra information returned with the // access token. Several // OAuth service providers (e.g. Yahoo, NetFlix) return a user id along // with the access // token, and the user id is required to use their APIs. Clients signal // that they need this // extra data by sending a fetch request for the access token URL. // // We don't return oauth* parameters from the response, because we know // how to handle those // ourselves and some of them (such as oauth_token_secret) aren't // supposed to be sent to the // client. // // Note that this data is not stored server-side. Clients need to cache // these user-ids or // other data themselves, probably in user prefs, if they expect to need // the data in the // future. if (accessTokenUri.equals(realRequest.getUri())) { accessTokenData = Maps.newHashMap(); for (Entry<String, String> param : OAuthUtil.getParameters(reply)) { if (!param.getKey().startsWith("oauth")) { accessTokenData.put(param.getKey(), param.getValue()); } } } } /** * Save off our new token and secret to the persistent store. */ private void saveAccessToken() throws OAuthResponseParams.OAuthRequestException { OAuthAccessor accessor = accessorInfo.getAccessor(); TokenInfo tokenInfo = new TokenInfo(accessor.accessToken, accessor.tokenSecret, accessorInfo.getSessionHandle(), accessorInfo.getTokenExpireMillis()); fetcherConfig.getTokenStore().storeTokenKeyAndSecret(realRequest.getSecurityToken(), accessorInfo.getConsumer(), realRequest.getOAuthArguments(), tokenInfo, responseParams); } /** * Builds the data we'll cache on the client while we make requests. */ private void buildClientAccessState() { OAuthAccessor accessor = accessorInfo.getAccessor(); responseParams.getNewClientState().setAccessToken(accessor.accessToken); responseParams.getNewClientState().setAccessTokenSecret(accessor.tokenSecret); responseParams.getNewClientState().setOwner(realRequest.getSecurityToken().getOwnerId()); responseParams.getNewClientState().setSessionHandle(accessorInfo.getSessionHandle()); responseParams.getNewClientState().setTokenExpireMillis(accessorInfo.getTokenExpireMillis()); } /** * Get honest-to-goodness user data. * * @throws NXOAuthProtocolException if the service provider returns an OAuth * related error instead of user data. */ private HttpResponseBuilder fetchData() throws OAuthResponseParams.OAuthRequestException, NXOAuthProtocolException { HttpResponseBuilder builder = null; if (accessTokenData != null) { // This is a request for access token data, return it. builder = formatAccessTokenData(); } else { HttpRequest signed = sanitizeAndSign(realRequest, null, false); HttpResponse response = fetchFromServer(signed); checkForProtocolProblem(response); builder = new HttpResponseBuilder(response); } return builder; } private HttpResponse fetchFromServer(HttpRequest request) throws OAuthResponseParams.OAuthRequestException { HttpResponse response = null; try { response = fetcher.fetch(request); if (response == null) { throw responseParams.oauthRequestException(OAuthError.UNKNOWN_PROBLEM, "No response from server"); } return response; } catch (GadgetException e) { throw responseParams.oauthRequestException(OAuthError.UNKNOWN_PROBLEM, "No response from server", e); } finally { responseParams.addRequestTrace(request, response); } } /** * Access token data is returned to the gadget as json key/value pairs: * * { "user_id": "12345678" } */ private HttpResponseBuilder formatAccessTokenData() { HttpResponseBuilder builder = new HttpResponseBuilder(); builder.addHeader("Content-Type", "application/json; charset=utf-8"); builder.setHttpStatusCode(HttpResponse.SC_OK); // no need to cache this, these requests should be fairly rare, and the // results should be // cached in gadget. builder.setStrictNoCache(); JSONObject json = new JSONObject(accessTokenData); builder.setResponseString(json.toString()); return builder; } /** * Look for an OAuth protocol problem. For cases where no access token is in * play * * @param response * @throws NXOAuthProtocolException */ private void checkForProtocolProblem(HttpResponse response) throws NXOAuthProtocolException { if (couldBeFullOAuthError(response)) { // OK, might be OAuth related. OAuthMessage message = parseAuthHeader(null, response); if (OAuthUtil.getParameter(message, OAuthProblemException.OAUTH_PROBLEM) != null) { // SP reported extended error information throw new NXOAuthProtocolException(response.getHttpStatusCode(), message); } // No extended information, guess based on HTTP response code. if (response.getHttpStatusCode() == HttpResponse.SC_UNAUTHORIZED) { throw new NXOAuthProtocolException(response.getHttpStatusCode()); } } } /** * Check if a response might be due to an OAuth protocol error. We don't * want to intercept errors for signed fetch, we only care about places * where we are dealing with OAuth request and/or access tokens. */ private boolean couldBeFullOAuthError(HttpResponse response) { // 400, 401 and 403 are likely to be authentication errors. // Unfortunately there is // significant overlap with other types of server errors as well, so we // can't just assume // that the root cause of these errors is a bad token or a bad consumer // key. if (response.getHttpStatusCode() != HttpResponse.SC_BAD_REQUEST && response.getHttpStatusCode() != HttpResponse.SC_UNAUTHORIZED && response.getHttpStatusCode() != HttpResponse.SC_FORBIDDEN) { return false; } // If the client forced us to use full OAuth, this might be OAuth // related. if (realRequest.getOAuthArguments().mustUseToken()) { return true; } // If we're using an access token, this might be OAuth related. if (accessorInfo.getAccessor().accessToken != null) { return true; } // Not OAuth related. return false; } /** * Extracts only those parameters from an OAuthMessage that are * OAuth-related. An OAuthMessage may hold a whole bunch of * non-OAuth-related parameters because they were all needed for signing. * But when constructing a request we need to be able to extract just the * OAuth-related parameters because they, and only they, may have to be put * into an Authorization: header or some such thing. * * @param message the OAuthMessage object, which holds non-OAuth parameters * such as foo=bar (which may have been in the original URI query * part, or perhaps in the POST body), as well as OAuth-related * parameters (such as oauth_timestamp or oauth_signature). * * @return a list that contains only the oauth_related parameters. */ static List<Map.Entry<String, String>> selectOAuthParams(OAuthMessage message) { List<Map.Entry<String, String>> result = Lists.newArrayList(); for (Map.Entry<String, String> param : OAuthUtil.getParameters(message)) { if (isContainerInjectedParameter(param.getKey())) { result.add(param); } } return result; } private static boolean isContainerInjectedParameter(String key) { key = key.toLowerCase(); return key.startsWith("oauth") || key.startsWith("xoauth") || key.startsWith("opensocial"); } } // should be OAuthProtocolException... class NXOAuthProtocolException extends Exception { /** * Problems that should force us to abort the protocol right away, and next * time the user visits ask them for permission again. */ private static Set<String> fatalProblems = ImmutableSet.of("version_rejected", "signature_method_rejected", "consumer_key_unknown", "consumer_key_rejected", "timestamp_refused"); /** * Problems that should force us to abort the protocol right away, but we * can still try to use the access token again later. */ private static Set<String> temporaryProblems = ImmutableSet.of("consumer_key_refused"); /** * Problems that should have us try to refresh the access token. */ private static Set<String> extensionProblems = ImmutableSet.of("access_token_expired"); private final boolean canRetry; private final boolean startFromScratch; private final boolean canExtend; private final String problemCode; public NXOAuthProtocolException(int status, OAuthMessage reply) { String problem = OAuthUtil.getParameter(reply, OAuthProblemException.OAUTH_PROBLEM); if (problem == null) { throw new IllegalArgumentException( "No problem reported for HorribleHackExceptionBecauseStupidClassIsPackagePrivate"); } this.problemCode = problem; if (fatalProblems.contains(problem)) { startFromScratch = true; canRetry = false; canExtend = false; } else if (temporaryProblems.contains(problem)) { startFromScratch = false; canRetry = false; canExtend = false; } else if (extensionProblems.contains(problem)) { startFromScratch = false; canRetry = true; canExtend = true; } else { // fallback to status to figure out behavior if (status == HttpResponse.SC_UNAUTHORIZED) { startFromScratch = true; canRetry = true; } else { startFromScratch = false; canRetry = false; } canExtend = false; } } /** * Handle OAuth protocol errors for SPs that don't support the problem * reporting extension * * @param status HTTP status code, assumed to be between 400 and 499 * inclusive */ public NXOAuthProtocolException(int status) { if (status == HttpResponse.SC_UNAUTHORIZED) { startFromScratch = true; canRetry = true; } else { startFromScratch = false; canRetry = false; } canExtend = false; problemCode = null; } /** * @return true if we've gotten confused to the point where we should give * up and ask the user for approval again. */ public boolean startFromScratch() { return startFromScratch; } /** * @return true if we think we can make progress by attempting the protocol * flow again (which may require starting from scratch). */ public boolean canRetry() { return canRetry; } /** * @return true if we think we can make progress by attempting to extend the * lifetime of the access token. */ public boolean canExtend() { return canExtend; } /** * @return the OAuth problem code (from the problem reporting extension). */ public String getProblemCode() { return problemCode; } }