mobile/android/base/tokenserver/TokenServerClient.java

changeset 0
6474c204b198
     1.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     1.2 +++ b/mobile/android/base/tokenserver/TokenServerClient.java	Wed Dec 31 06:09:35 2014 +0100
     1.3 @@ -0,0 +1,330 @@
     1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public
     1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this
     1.6 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
     1.7 +
     1.8 +package org.mozilla.gecko.tokenserver;
     1.9 +
    1.10 +import java.io.IOException;
    1.11 +import java.net.URI;
    1.12 +import java.security.GeneralSecurityException;
    1.13 +import java.util.ArrayList;
    1.14 +import java.util.List;
    1.15 +import java.util.concurrent.Executor;
    1.16 +
    1.17 +import org.json.simple.JSONObject;
    1.18 +import org.mozilla.gecko.background.common.log.Logger;
    1.19 +import org.mozilla.gecko.background.fxa.SkewHandler;
    1.20 +import org.mozilla.gecko.sync.ExtendedJSONObject;
    1.21 +import org.mozilla.gecko.sync.NonArrayJSONException;
    1.22 +import org.mozilla.gecko.sync.NonObjectJSONException;
    1.23 +import org.mozilla.gecko.sync.UnexpectedJSONException.BadRequiredFieldJSONException;
    1.24 +import org.mozilla.gecko.sync.net.AuthHeaderProvider;
    1.25 +import org.mozilla.gecko.sync.net.BaseResource;
    1.26 +import org.mozilla.gecko.sync.net.BaseResourceDelegate;
    1.27 +import org.mozilla.gecko.sync.net.BrowserIDAuthHeaderProvider;
    1.28 +import org.mozilla.gecko.sync.net.SyncResponse;
    1.29 +import org.mozilla.gecko.tokenserver.TokenServerException.TokenServerConditionsRequiredException;
    1.30 +import org.mozilla.gecko.tokenserver.TokenServerException.TokenServerInvalidCredentialsException;
    1.31 +import org.mozilla.gecko.tokenserver.TokenServerException.TokenServerMalformedRequestException;
    1.32 +import org.mozilla.gecko.tokenserver.TokenServerException.TokenServerMalformedResponseException;
    1.33 +import org.mozilla.gecko.tokenserver.TokenServerException.TokenServerUnknownServiceException;
    1.34 +
    1.35 +import ch.boye.httpclientandroidlib.Header;
    1.36 +import ch.boye.httpclientandroidlib.HttpHeaders;
    1.37 +import ch.boye.httpclientandroidlib.HttpResponse;
    1.38 +import ch.boye.httpclientandroidlib.client.ClientProtocolException;
    1.39 +import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
    1.40 +import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
    1.41 +import ch.boye.httpclientandroidlib.message.BasicHeader;
    1.42 +
    1.43 +/**
    1.44 + * HTTP client for interacting with the Mozilla Services Token Server API v1.0,
    1.45 + * as documented at
    1.46 + * <a href="http://docs.services.mozilla.com/token/apis.html">http://docs.services.mozilla.com/token/apis.html</a>.
    1.47 + * <p>
    1.48 + * A token server accepts some authorization credential and returns a different
    1.49 + * authorization credential. Usually, it used to exchange a public-key
    1.50 + * authorization token that is expensive to validate for a symmetric-key
    1.51 + * authorization that is cheap to validate. For example, we might exchange a
    1.52 + * BrowserID assertion for a HAWK id and key pair.
    1.53 + */
    1.54 +public class TokenServerClient {
    1.55 +  protected static final String LOG_TAG = "TokenServerClient";
    1.56 +
    1.57 +  public static final String JSON_KEY_API_ENDPOINT = "api_endpoint";
    1.58 +  public static final String JSON_KEY_CONDITION_URLS = "condition_urls";
    1.59 +  public static final String JSON_KEY_DURATION = "duration";
    1.60 +  public static final String JSON_KEY_ERRORS = "errors";
    1.61 +  public static final String JSON_KEY_ID = "id";
    1.62 +  public static final String JSON_KEY_KEY = "key";
    1.63 +  public static final String JSON_KEY_UID = "uid";
    1.64 +
    1.65 +  public static final String HEADER_CONDITIONS_ACCEPTED = "X-Conditions-Accepted";
    1.66 +  public static final String HEADER_CLIENT_STATE = "X-Client-State";
    1.67 +
    1.68 +  protected final Executor executor;
    1.69 +  protected final URI uri;
    1.70 +
    1.71 +  public TokenServerClient(URI uri, Executor executor) {
    1.72 +    if (uri == null) {
    1.73 +      throw new IllegalArgumentException("uri must not be null");
    1.74 +    }
    1.75 +    if (executor == null) {
    1.76 +      throw new IllegalArgumentException("executor must not be null");
    1.77 +    }
    1.78 +    this.uri = uri;
    1.79 +    this.executor = executor;
    1.80 +  }
    1.81 +
    1.82 +  protected void invokeHandleSuccess(final TokenServerClientDelegate delegate, final TokenServerToken token) {
    1.83 +    executor.execute(new Runnable() {
    1.84 +      @Override
    1.85 +      public void run() {
    1.86 +        delegate.handleSuccess(token);
    1.87 +      }
    1.88 +    });
    1.89 +  }
    1.90 +
    1.91 +  protected void invokeHandleFailure(final TokenServerClientDelegate delegate, final TokenServerException e) {
    1.92 +    executor.execute(new Runnable() {
    1.93 +      @Override
    1.94 +      public void run() {
    1.95 +        delegate.handleFailure(e);
    1.96 +      }
    1.97 +    });
    1.98 +  }
    1.99 +
   1.100 +  /**
   1.101 +   * Notify the delegate that some kind of backoff header (X-Backoff,
   1.102 +   * X-Weave-Backoff, Retry-After) was received and should be acted upon.
   1.103 +   *
   1.104 +   * This method is non-terminal, and will be followed by a separate
   1.105 +   * <code>invoke*</code> call.
   1.106 +   *
   1.107 +   * @param delegate
   1.108 +   *          the delegate to inform.
   1.109 +   * @param backoffSeconds
   1.110 +   *          the number of seconds for which the system should wait before
   1.111 +   *          making another token server request to this server.
   1.112 +   */
   1.113 +  protected void notifyBackoff(final TokenServerClientDelegate delegate, final int backoffSeconds) {
   1.114 +    executor.execute(new Runnable() {
   1.115 +      @Override
   1.116 +      public void run() {
   1.117 +        delegate.handleBackoff(backoffSeconds);
   1.118 +      }
   1.119 +    });
   1.120 +  }
   1.121 +
   1.122 +  protected void invokeHandleError(final TokenServerClientDelegate delegate, final Exception e) {
   1.123 +    executor.execute(new Runnable() {
   1.124 +      @Override
   1.125 +      public void run() {
   1.126 +        delegate.handleError(e);
   1.127 +      }
   1.128 +    });
   1.129 +  }
   1.130 +
   1.131 +  public TokenServerToken processResponse(SyncResponse res) throws TokenServerException {
   1.132 +    int statusCode = res.getStatusCode();
   1.133 +
   1.134 +    Logger.debug(LOG_TAG, "Got token response with status code " + statusCode + ".");
   1.135 +
   1.136 +    // Responses should *always* be JSON, even in the case of 4xx and 5xx
   1.137 +    // errors. If we don't see JSON, the server is likely very unhappy.
   1.138 +    final Header contentType = res.getContentType();
   1.139 +    if (contentType == null) {
   1.140 +      throw new TokenServerMalformedResponseException(null, "Non-JSON response Content-Type.");
   1.141 +    }
   1.142 +
   1.143 +    final String type = contentType.getValue();
   1.144 +    if (!type.equals("application/json") &&
   1.145 +        !type.startsWith("application/json;")) {
   1.146 +      Logger.warn(LOG_TAG, "Got non-JSON response with Content-Type " +
   1.147 +          contentType + ". Misconfigured server?");
   1.148 +      throw new TokenServerMalformedResponseException(null, "Non-JSON response Content-Type.");
   1.149 +    }
   1.150 +
   1.151 +    // Responses should *always* be a valid JSON object.
   1.152 +    // It turns out that right now they're not always, but that's a server bug...
   1.153 +    ExtendedJSONObject result;
   1.154 +    try {
   1.155 +      result = res.jsonObjectBody();
   1.156 +    } catch (Exception e) {
   1.157 +      Logger.debug(LOG_TAG, "Malformed token response.", e);
   1.158 +      throw new TokenServerMalformedResponseException(null, e);
   1.159 +    }
   1.160 +
   1.161 +    // The service shouldn't have any 3xx, so we don't need to handle those.
   1.162 +    if (res.getStatusCode() != 200) {
   1.163 +      // We should have a (Cornice) error report in the JSON. We log that to
   1.164 +      // help with debugging.
   1.165 +      List<ExtendedJSONObject> errorList = new ArrayList<ExtendedJSONObject>();
   1.166 +
   1.167 +      if (result.containsKey(JSON_KEY_ERRORS)) {
   1.168 +        try {
   1.169 +          for (Object error : result.getArray(JSON_KEY_ERRORS)) {
   1.170 +            Logger.warn(LOG_TAG, "" + error);
   1.171 +
   1.172 +            if (error instanceof JSONObject) {
   1.173 +              errorList.add(new ExtendedJSONObject((JSONObject) error));
   1.174 +            }
   1.175 +          }
   1.176 +        } catch (NonArrayJSONException e) {
   1.177 +          Logger.warn(LOG_TAG, "Got non-JSON array '" + result.getString(JSON_KEY_ERRORS) + "'.", e);
   1.178 +        }
   1.179 +      }
   1.180 +
   1.181 +      if (statusCode == 400) {
   1.182 +        throw new TokenServerMalformedRequestException(errorList, result.toJSONString());
   1.183 +      }
   1.184 +
   1.185 +      if (statusCode == 401) {
   1.186 +        throw new TokenServerInvalidCredentialsException(errorList, result.toJSONString());
   1.187 +      }
   1.188 +
   1.189 +      // 403 should represent a "condition acceptance needed" response.
   1.190 +      //
   1.191 +      // The extra validation of "urls" is important. We don't want to signal
   1.192 +      // conditions required unless we are absolutely sure that is what the
   1.193 +      // server is asking for.
   1.194 +      if (statusCode == 403) {
   1.195 +        // Bug 792674 and Bug 783598: make this testing simpler. For now, we
   1.196 +        // check that errors is an array, and take any condition_urls from the
   1.197 +        // first element.
   1.198 +
   1.199 +        try {
   1.200 +          if (errorList == null || errorList.isEmpty()) {
   1.201 +            throw new TokenServerMalformedResponseException(errorList, "403 response without proper fields.");
   1.202 +          }
   1.203 +
   1.204 +          ExtendedJSONObject error = errorList.get(0);
   1.205 +
   1.206 +          ExtendedJSONObject condition_urls = error.getObject(JSON_KEY_CONDITION_URLS);
   1.207 +          if (condition_urls != null) {
   1.208 +            throw new TokenServerConditionsRequiredException(condition_urls);
   1.209 +          }
   1.210 +        } catch (NonObjectJSONException e) {
   1.211 +          Logger.warn(LOG_TAG, "Got non-JSON error object.");
   1.212 +        }
   1.213 +
   1.214 +        throw new TokenServerMalformedResponseException(errorList, "403 response without proper fields.");
   1.215 +      }
   1.216 +
   1.217 +      if (statusCode == 404) {
   1.218 +        throw new TokenServerUnknownServiceException(errorList);
   1.219 +      }
   1.220 +
   1.221 +      // We shouldn't ever get here...
   1.222 +      throw new TokenServerException(errorList);
   1.223 +    }
   1.224 +
   1.225 +    try {
   1.226 +      result.throwIfFieldsMissingOrMisTyped(new String[] { JSON_KEY_ID, JSON_KEY_KEY, JSON_KEY_API_ENDPOINT }, String.class);
   1.227 +      result.throwIfFieldsMissingOrMisTyped(new String[] { JSON_KEY_UID }, Long.class);
   1.228 +    } catch (BadRequiredFieldJSONException e ) {
   1.229 +      throw new TokenServerMalformedResponseException(null, e);
   1.230 +    }
   1.231 +
   1.232 +    Logger.debug(LOG_TAG, "Successful token response: " + result.getString(JSON_KEY_ID));
   1.233 +
   1.234 +    return new TokenServerToken(result.getString(JSON_KEY_ID),
   1.235 +        result.getString(JSON_KEY_KEY),
   1.236 +        result.get(JSON_KEY_UID).toString(),
   1.237 +        result.getString(JSON_KEY_API_ENDPOINT));
   1.238 +  }
   1.239 +
   1.240 +  public static class TokenFetchResourceDelegate extends BaseResourceDelegate {
   1.241 +    private final TokenServerClient         client;
   1.242 +    private final TokenServerClientDelegate delegate;
   1.243 +    private final String                    assertion;
   1.244 +    private final String                    clientState;
   1.245 +    private final BaseResource              resource;
   1.246 +    private final boolean                   conditionsAccepted;
   1.247 +
   1.248 +    public TokenFetchResourceDelegate(TokenServerClient client,
   1.249 +                                      BaseResource resource,
   1.250 +                                      TokenServerClientDelegate delegate,
   1.251 +                                      String assertion, String clientState,
   1.252 +                                      boolean conditionsAccepted) {
   1.253 +      super(resource);
   1.254 +      this.client = client;
   1.255 +      this.delegate = delegate;
   1.256 +      this.assertion = assertion;
   1.257 +      this.clientState = clientState;
   1.258 +      this.resource = resource;
   1.259 +      this.conditionsAccepted = conditionsAccepted;
   1.260 +    }
   1.261 +
   1.262 +    @Override
   1.263 +    public String getUserAgent() {
   1.264 +      return delegate.getUserAgent();
   1.265 +    }
   1.266 +
   1.267 +    @Override
   1.268 +    public void handleHttpResponse(HttpResponse response) {
   1.269 +      // Skew.
   1.270 +      SkewHandler skewHandler = SkewHandler.getSkewHandlerForResource(resource);
   1.271 +      skewHandler.updateSkew(response, System.currentTimeMillis());
   1.272 +
   1.273 +      // Extract backoff regardless of whether this was an error response, and
   1.274 +      // Retry-After for 503 responses. The error will be handled elsewhere.)
   1.275 +      SyncResponse res = new SyncResponse(response);
   1.276 +      final boolean includeRetryAfter = res.getStatusCode() == 503;
   1.277 +      int backoffInSeconds = res.totalBackoffInSeconds(includeRetryAfter);
   1.278 +      if (backoffInSeconds > -1) {
   1.279 +        client.notifyBackoff(delegate, backoffInSeconds);
   1.280 +      }
   1.281 +
   1.282 +      try {
   1.283 +        TokenServerToken token = client.processResponse(res);
   1.284 +        client.invokeHandleSuccess(delegate, token);
   1.285 +      } catch (TokenServerException e) {
   1.286 +        client.invokeHandleFailure(delegate, e);
   1.287 +      }
   1.288 +    }
   1.289 +
   1.290 +    @Override
   1.291 +    public void handleTransportException(GeneralSecurityException e) {
   1.292 +      client.invokeHandleError(delegate, e);
   1.293 +    }
   1.294 +
   1.295 +    @Override
   1.296 +    public void handleHttpProtocolException(ClientProtocolException e) {
   1.297 +      client.invokeHandleError(delegate, e);
   1.298 +    }
   1.299 +
   1.300 +    @Override
   1.301 +    public void handleHttpIOException(IOException e) {
   1.302 +      client.invokeHandleError(delegate, e);
   1.303 +    }
   1.304 +
   1.305 +    @Override
   1.306 +    public AuthHeaderProvider getAuthHeaderProvider() {
   1.307 +      return new BrowserIDAuthHeaderProvider(assertion);
   1.308 +    }
   1.309 +
   1.310 +    @Override
   1.311 +    public void addHeaders(HttpRequestBase request, DefaultHttpClient client) {
   1.312 +      String host = request.getURI().getHost();
   1.313 +      request.setHeader(new BasicHeader(HttpHeaders.HOST, host));
   1.314 +      if (clientState != null) {
   1.315 +        request.setHeader(new BasicHeader(HEADER_CLIENT_STATE, clientState));
   1.316 +      }
   1.317 +      if (conditionsAccepted) {
   1.318 +        request.addHeader(HEADER_CONDITIONS_ACCEPTED, "1");
   1.319 +      }
   1.320 +    }
   1.321 +  }
   1.322 +
   1.323 +  public void getTokenFromBrowserIDAssertion(final String assertion,
   1.324 +                                             final boolean conditionsAccepted,
   1.325 +                                             final String clientState,
   1.326 +                                             final TokenServerClientDelegate delegate) {
   1.327 +    final BaseResource resource = new BaseResource(this.uri);
   1.328 +    resource.delegate = new TokenFetchResourceDelegate(this, resource, delegate,
   1.329 +                                                       assertion, clientState,
   1.330 +                                                       conditionsAccepted);
   1.331 +    resource.get();
   1.332 +  }
   1.333 +}

mercurial