michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: package org.mozilla.gecko.tokenserver; michael@0: michael@0: import java.io.IOException; michael@0: import java.net.URI; michael@0: import java.security.GeneralSecurityException; michael@0: import java.util.ArrayList; michael@0: import java.util.List; michael@0: import java.util.concurrent.Executor; michael@0: michael@0: import org.json.simple.JSONObject; michael@0: import org.mozilla.gecko.background.common.log.Logger; michael@0: import org.mozilla.gecko.background.fxa.SkewHandler; michael@0: import org.mozilla.gecko.sync.ExtendedJSONObject; michael@0: import org.mozilla.gecko.sync.NonArrayJSONException; michael@0: import org.mozilla.gecko.sync.NonObjectJSONException; michael@0: import org.mozilla.gecko.sync.UnexpectedJSONException.BadRequiredFieldJSONException; michael@0: import org.mozilla.gecko.sync.net.AuthHeaderProvider; michael@0: import org.mozilla.gecko.sync.net.BaseResource; michael@0: import org.mozilla.gecko.sync.net.BaseResourceDelegate; michael@0: import org.mozilla.gecko.sync.net.BrowserIDAuthHeaderProvider; michael@0: import org.mozilla.gecko.sync.net.SyncResponse; michael@0: import org.mozilla.gecko.tokenserver.TokenServerException.TokenServerConditionsRequiredException; michael@0: import org.mozilla.gecko.tokenserver.TokenServerException.TokenServerInvalidCredentialsException; michael@0: import org.mozilla.gecko.tokenserver.TokenServerException.TokenServerMalformedRequestException; michael@0: import org.mozilla.gecko.tokenserver.TokenServerException.TokenServerMalformedResponseException; michael@0: import org.mozilla.gecko.tokenserver.TokenServerException.TokenServerUnknownServiceException; michael@0: michael@0: import ch.boye.httpclientandroidlib.Header; michael@0: import ch.boye.httpclientandroidlib.HttpHeaders; michael@0: import ch.boye.httpclientandroidlib.HttpResponse; michael@0: import ch.boye.httpclientandroidlib.client.ClientProtocolException; michael@0: import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase; michael@0: import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient; michael@0: import ch.boye.httpclientandroidlib.message.BasicHeader; michael@0: michael@0: /** michael@0: * HTTP client for interacting with the Mozilla Services Token Server API v1.0, michael@0: * as documented at michael@0: * http://docs.services.mozilla.com/token/apis.html. michael@0: *

michael@0: * A token server accepts some authorization credential and returns a different michael@0: * authorization credential. Usually, it used to exchange a public-key michael@0: * authorization token that is expensive to validate for a symmetric-key michael@0: * authorization that is cheap to validate. For example, we might exchange a michael@0: * BrowserID assertion for a HAWK id and key pair. michael@0: */ michael@0: public class TokenServerClient { michael@0: protected static final String LOG_TAG = "TokenServerClient"; michael@0: michael@0: public static final String JSON_KEY_API_ENDPOINT = "api_endpoint"; michael@0: public static final String JSON_KEY_CONDITION_URLS = "condition_urls"; michael@0: public static final String JSON_KEY_DURATION = "duration"; michael@0: public static final String JSON_KEY_ERRORS = "errors"; michael@0: public static final String JSON_KEY_ID = "id"; michael@0: public static final String JSON_KEY_KEY = "key"; michael@0: public static final String JSON_KEY_UID = "uid"; michael@0: michael@0: public static final String HEADER_CONDITIONS_ACCEPTED = "X-Conditions-Accepted"; michael@0: public static final String HEADER_CLIENT_STATE = "X-Client-State"; michael@0: michael@0: protected final Executor executor; michael@0: protected final URI uri; michael@0: michael@0: public TokenServerClient(URI uri, Executor executor) { michael@0: if (uri == null) { michael@0: throw new IllegalArgumentException("uri must not be null"); michael@0: } michael@0: if (executor == null) { michael@0: throw new IllegalArgumentException("executor must not be null"); michael@0: } michael@0: this.uri = uri; michael@0: this.executor = executor; michael@0: } michael@0: michael@0: protected void invokeHandleSuccess(final TokenServerClientDelegate delegate, final TokenServerToken token) { michael@0: executor.execute(new Runnable() { michael@0: @Override michael@0: public void run() { michael@0: delegate.handleSuccess(token); michael@0: } michael@0: }); michael@0: } michael@0: michael@0: protected void invokeHandleFailure(final TokenServerClientDelegate delegate, final TokenServerException e) { michael@0: executor.execute(new Runnable() { michael@0: @Override michael@0: public void run() { michael@0: delegate.handleFailure(e); michael@0: } michael@0: }); michael@0: } michael@0: michael@0: /** michael@0: * Notify the delegate that some kind of backoff header (X-Backoff, michael@0: * X-Weave-Backoff, Retry-After) was received and should be acted upon. michael@0: * michael@0: * This method is non-terminal, and will be followed by a separate michael@0: * invoke* call. michael@0: * michael@0: * @param delegate michael@0: * the delegate to inform. michael@0: * @param backoffSeconds michael@0: * the number of seconds for which the system should wait before michael@0: * making another token server request to this server. michael@0: */ michael@0: protected void notifyBackoff(final TokenServerClientDelegate delegate, final int backoffSeconds) { michael@0: executor.execute(new Runnable() { michael@0: @Override michael@0: public void run() { michael@0: delegate.handleBackoff(backoffSeconds); michael@0: } michael@0: }); michael@0: } michael@0: michael@0: protected void invokeHandleError(final TokenServerClientDelegate delegate, final Exception e) { michael@0: executor.execute(new Runnable() { michael@0: @Override michael@0: public void run() { michael@0: delegate.handleError(e); michael@0: } michael@0: }); michael@0: } michael@0: michael@0: public TokenServerToken processResponse(SyncResponse res) throws TokenServerException { michael@0: int statusCode = res.getStatusCode(); michael@0: michael@0: Logger.debug(LOG_TAG, "Got token response with status code " + statusCode + "."); michael@0: michael@0: // Responses should *always* be JSON, even in the case of 4xx and 5xx michael@0: // errors. If we don't see JSON, the server is likely very unhappy. michael@0: final Header contentType = res.getContentType(); michael@0: if (contentType == null) { michael@0: throw new TokenServerMalformedResponseException(null, "Non-JSON response Content-Type."); michael@0: } michael@0: michael@0: final String type = contentType.getValue(); michael@0: if (!type.equals("application/json") && michael@0: !type.startsWith("application/json;")) { michael@0: Logger.warn(LOG_TAG, "Got non-JSON response with Content-Type " + michael@0: contentType + ". Misconfigured server?"); michael@0: throw new TokenServerMalformedResponseException(null, "Non-JSON response Content-Type."); michael@0: } michael@0: michael@0: // Responses should *always* be a valid JSON object. michael@0: // It turns out that right now they're not always, but that's a server bug... michael@0: ExtendedJSONObject result; michael@0: try { michael@0: result = res.jsonObjectBody(); michael@0: } catch (Exception e) { michael@0: Logger.debug(LOG_TAG, "Malformed token response.", e); michael@0: throw new TokenServerMalformedResponseException(null, e); michael@0: } michael@0: michael@0: // The service shouldn't have any 3xx, so we don't need to handle those. michael@0: if (res.getStatusCode() != 200) { michael@0: // We should have a (Cornice) error report in the JSON. We log that to michael@0: // help with debugging. michael@0: List errorList = new ArrayList(); michael@0: michael@0: if (result.containsKey(JSON_KEY_ERRORS)) { michael@0: try { michael@0: for (Object error : result.getArray(JSON_KEY_ERRORS)) { michael@0: Logger.warn(LOG_TAG, "" + error); michael@0: michael@0: if (error instanceof JSONObject) { michael@0: errorList.add(new ExtendedJSONObject((JSONObject) error)); michael@0: } michael@0: } michael@0: } catch (NonArrayJSONException e) { michael@0: Logger.warn(LOG_TAG, "Got non-JSON array '" + result.getString(JSON_KEY_ERRORS) + "'.", e); michael@0: } michael@0: } michael@0: michael@0: if (statusCode == 400) { michael@0: throw new TokenServerMalformedRequestException(errorList, result.toJSONString()); michael@0: } michael@0: michael@0: if (statusCode == 401) { michael@0: throw new TokenServerInvalidCredentialsException(errorList, result.toJSONString()); michael@0: } michael@0: michael@0: // 403 should represent a "condition acceptance needed" response. michael@0: // michael@0: // The extra validation of "urls" is important. We don't want to signal michael@0: // conditions required unless we are absolutely sure that is what the michael@0: // server is asking for. michael@0: if (statusCode == 403) { michael@0: // Bug 792674 and Bug 783598: make this testing simpler. For now, we michael@0: // check that errors is an array, and take any condition_urls from the michael@0: // first element. michael@0: michael@0: try { michael@0: if (errorList == null || errorList.isEmpty()) { michael@0: throw new TokenServerMalformedResponseException(errorList, "403 response without proper fields."); michael@0: } michael@0: michael@0: ExtendedJSONObject error = errorList.get(0); michael@0: michael@0: ExtendedJSONObject condition_urls = error.getObject(JSON_KEY_CONDITION_URLS); michael@0: if (condition_urls != null) { michael@0: throw new TokenServerConditionsRequiredException(condition_urls); michael@0: } michael@0: } catch (NonObjectJSONException e) { michael@0: Logger.warn(LOG_TAG, "Got non-JSON error object."); michael@0: } michael@0: michael@0: throw new TokenServerMalformedResponseException(errorList, "403 response without proper fields."); michael@0: } michael@0: michael@0: if (statusCode == 404) { michael@0: throw new TokenServerUnknownServiceException(errorList); michael@0: } michael@0: michael@0: // We shouldn't ever get here... michael@0: throw new TokenServerException(errorList); michael@0: } michael@0: michael@0: try { michael@0: result.throwIfFieldsMissingOrMisTyped(new String[] { JSON_KEY_ID, JSON_KEY_KEY, JSON_KEY_API_ENDPOINT }, String.class); michael@0: result.throwIfFieldsMissingOrMisTyped(new String[] { JSON_KEY_UID }, Long.class); michael@0: } catch (BadRequiredFieldJSONException e ) { michael@0: throw new TokenServerMalformedResponseException(null, e); michael@0: } michael@0: michael@0: Logger.debug(LOG_TAG, "Successful token response: " + result.getString(JSON_KEY_ID)); michael@0: michael@0: return new TokenServerToken(result.getString(JSON_KEY_ID), michael@0: result.getString(JSON_KEY_KEY), michael@0: result.get(JSON_KEY_UID).toString(), michael@0: result.getString(JSON_KEY_API_ENDPOINT)); michael@0: } michael@0: michael@0: public static class TokenFetchResourceDelegate extends BaseResourceDelegate { michael@0: private final TokenServerClient client; michael@0: private final TokenServerClientDelegate delegate; michael@0: private final String assertion; michael@0: private final String clientState; michael@0: private final BaseResource resource; michael@0: private final boolean conditionsAccepted; michael@0: michael@0: public TokenFetchResourceDelegate(TokenServerClient client, michael@0: BaseResource resource, michael@0: TokenServerClientDelegate delegate, michael@0: String assertion, String clientState, michael@0: boolean conditionsAccepted) { michael@0: super(resource); michael@0: this.client = client; michael@0: this.delegate = delegate; michael@0: this.assertion = assertion; michael@0: this.clientState = clientState; michael@0: this.resource = resource; michael@0: this.conditionsAccepted = conditionsAccepted; michael@0: } michael@0: michael@0: @Override michael@0: public String getUserAgent() { michael@0: return delegate.getUserAgent(); michael@0: } michael@0: michael@0: @Override michael@0: public void handleHttpResponse(HttpResponse response) { michael@0: // Skew. michael@0: SkewHandler skewHandler = SkewHandler.getSkewHandlerForResource(resource); michael@0: skewHandler.updateSkew(response, System.currentTimeMillis()); michael@0: michael@0: // Extract backoff regardless of whether this was an error response, and michael@0: // Retry-After for 503 responses. The error will be handled elsewhere.) michael@0: SyncResponse res = new SyncResponse(response); michael@0: final boolean includeRetryAfter = res.getStatusCode() == 503; michael@0: int backoffInSeconds = res.totalBackoffInSeconds(includeRetryAfter); michael@0: if (backoffInSeconds > -1) { michael@0: client.notifyBackoff(delegate, backoffInSeconds); michael@0: } michael@0: michael@0: try { michael@0: TokenServerToken token = client.processResponse(res); michael@0: client.invokeHandleSuccess(delegate, token); michael@0: } catch (TokenServerException e) { michael@0: client.invokeHandleFailure(delegate, e); michael@0: } michael@0: } michael@0: michael@0: @Override michael@0: public void handleTransportException(GeneralSecurityException e) { michael@0: client.invokeHandleError(delegate, e); michael@0: } michael@0: michael@0: @Override michael@0: public void handleHttpProtocolException(ClientProtocolException e) { michael@0: client.invokeHandleError(delegate, e); michael@0: } michael@0: michael@0: @Override michael@0: public void handleHttpIOException(IOException e) { michael@0: client.invokeHandleError(delegate, e); michael@0: } michael@0: michael@0: @Override michael@0: public AuthHeaderProvider getAuthHeaderProvider() { michael@0: return new BrowserIDAuthHeaderProvider(assertion); michael@0: } michael@0: michael@0: @Override michael@0: public void addHeaders(HttpRequestBase request, DefaultHttpClient client) { michael@0: String host = request.getURI().getHost(); michael@0: request.setHeader(new BasicHeader(HttpHeaders.HOST, host)); michael@0: if (clientState != null) { michael@0: request.setHeader(new BasicHeader(HEADER_CLIENT_STATE, clientState)); michael@0: } michael@0: if (conditionsAccepted) { michael@0: request.addHeader(HEADER_CONDITIONS_ACCEPTED, "1"); michael@0: } michael@0: } michael@0: } michael@0: michael@0: public void getTokenFromBrowserIDAssertion(final String assertion, michael@0: final boolean conditionsAccepted, michael@0: final String clientState, michael@0: final TokenServerClientDelegate delegate) { michael@0: final BaseResource resource = new BaseResource(this.uri); michael@0: resource.delegate = new TokenFetchResourceDelegate(this, resource, delegate, michael@0: assertion, clientState, michael@0: conditionsAccepted); michael@0: resource.get(); michael@0: } michael@0: }