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

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