summaryrefslogtreecommitdiffstats
path: root/mobile/android/services/src/main/java/org/mozilla/gecko/tokenserver/TokenServerClient.java
blob: 9ee014dcbcf12be3cd6eee4f37115b96059477e8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
/* 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
 * <a href="http://docs.services.mozilla.com/token/apis.html">http://docs.services.mozilla.com/token/apis.html</a>.
 * <p>
 * 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
   * <code>invoke*</code> 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<ExtendedJSONObject> errorList = new ArrayList<ExtendedJSONObject>();

      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 '" + 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();
  }
}