mobile/android/base/tokenserver/TokenServerClient.java

branch
TOR_BUG_3246
changeset 4
fc2d59ddac77
equal deleted inserted replaced
-1:000000000000 0:ba5b0d9ef1cf
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5 package org.mozilla.gecko.tokenserver;
6
7 import java.io.IOException;
8 import java.net.URI;
9 import java.security.GeneralSecurityException;
10 import java.util.ArrayList;
11 import java.util.List;
12 import java.util.concurrent.Executor;
13
14 import org.json.simple.JSONObject;
15 import org.mozilla.gecko.background.common.log.Logger;
16 import org.mozilla.gecko.background.fxa.SkewHandler;
17 import org.mozilla.gecko.sync.ExtendedJSONObject;
18 import org.mozilla.gecko.sync.NonArrayJSONException;
19 import org.mozilla.gecko.sync.NonObjectJSONException;
20 import org.mozilla.gecko.sync.UnexpectedJSONException.BadRequiredFieldJSONException;
21 import org.mozilla.gecko.sync.net.AuthHeaderProvider;
22 import org.mozilla.gecko.sync.net.BaseResource;
23 import org.mozilla.gecko.sync.net.BaseResourceDelegate;
24 import org.mozilla.gecko.sync.net.BrowserIDAuthHeaderProvider;
25 import org.mozilla.gecko.sync.net.SyncResponse;
26 import org.mozilla.gecko.tokenserver.TokenServerException.TokenServerConditionsRequiredException;
27 import org.mozilla.gecko.tokenserver.TokenServerException.TokenServerInvalidCredentialsException;
28 import org.mozilla.gecko.tokenserver.TokenServerException.TokenServerMalformedRequestException;
29 import org.mozilla.gecko.tokenserver.TokenServerException.TokenServerMalformedResponseException;
30 import org.mozilla.gecko.tokenserver.TokenServerException.TokenServerUnknownServiceException;
31
32 import ch.boye.httpclientandroidlib.Header;
33 import ch.boye.httpclientandroidlib.HttpHeaders;
34 import ch.boye.httpclientandroidlib.HttpResponse;
35 import ch.boye.httpclientandroidlib.client.ClientProtocolException;
36 import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
37 import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
38 import ch.boye.httpclientandroidlib.message.BasicHeader;
39
40 /**
41 * HTTP client for interacting with the Mozilla Services Token Server API v1.0,
42 * as documented at
43 * <a href="http://docs.services.mozilla.com/token/apis.html">http://docs.services.mozilla.com/token/apis.html</a>.
44 * <p>
45 * A token server accepts some authorization credential and returns a different
46 * authorization credential. Usually, it used to exchange a public-key
47 * authorization token that is expensive to validate for a symmetric-key
48 * authorization that is cheap to validate. For example, we might exchange a
49 * BrowserID assertion for a HAWK id and key pair.
50 */
51 public class TokenServerClient {
52 protected static final String LOG_TAG = "TokenServerClient";
53
54 public static final String JSON_KEY_API_ENDPOINT = "api_endpoint";
55 public static final String JSON_KEY_CONDITION_URLS = "condition_urls";
56 public static final String JSON_KEY_DURATION = "duration";
57 public static final String JSON_KEY_ERRORS = "errors";
58 public static final String JSON_KEY_ID = "id";
59 public static final String JSON_KEY_KEY = "key";
60 public static final String JSON_KEY_UID = "uid";
61
62 public static final String HEADER_CONDITIONS_ACCEPTED = "X-Conditions-Accepted";
63 public static final String HEADER_CLIENT_STATE = "X-Client-State";
64
65 protected final Executor executor;
66 protected final URI uri;
67
68 public TokenServerClient(URI uri, Executor executor) {
69 if (uri == null) {
70 throw new IllegalArgumentException("uri must not be null");
71 }
72 if (executor == null) {
73 throw new IllegalArgumentException("executor must not be null");
74 }
75 this.uri = uri;
76 this.executor = executor;
77 }
78
79 protected void invokeHandleSuccess(final TokenServerClientDelegate delegate, final TokenServerToken token) {
80 executor.execute(new Runnable() {
81 @Override
82 public void run() {
83 delegate.handleSuccess(token);
84 }
85 });
86 }
87
88 protected void invokeHandleFailure(final TokenServerClientDelegate delegate, final TokenServerException e) {
89 executor.execute(new Runnable() {
90 @Override
91 public void run() {
92 delegate.handleFailure(e);
93 }
94 });
95 }
96
97 /**
98 * Notify the delegate that some kind of backoff header (X-Backoff,
99 * X-Weave-Backoff, Retry-After) was received and should be acted upon.
100 *
101 * This method is non-terminal, and will be followed by a separate
102 * <code>invoke*</code> call.
103 *
104 * @param delegate
105 * the delegate to inform.
106 * @param backoffSeconds
107 * the number of seconds for which the system should wait before
108 * making another token server request to this server.
109 */
110 protected void notifyBackoff(final TokenServerClientDelegate delegate, final int backoffSeconds) {
111 executor.execute(new Runnable() {
112 @Override
113 public void run() {
114 delegate.handleBackoff(backoffSeconds);
115 }
116 });
117 }
118
119 protected void invokeHandleError(final TokenServerClientDelegate delegate, final Exception e) {
120 executor.execute(new Runnable() {
121 @Override
122 public void run() {
123 delegate.handleError(e);
124 }
125 });
126 }
127
128 public TokenServerToken processResponse(SyncResponse res) throws TokenServerException {
129 int statusCode = res.getStatusCode();
130
131 Logger.debug(LOG_TAG, "Got token response with status code " + statusCode + ".");
132
133 // Responses should *always* be JSON, even in the case of 4xx and 5xx
134 // errors. If we don't see JSON, the server is likely very unhappy.
135 final Header contentType = res.getContentType();
136 if (contentType == null) {
137 throw new TokenServerMalformedResponseException(null, "Non-JSON response Content-Type.");
138 }
139
140 final String type = contentType.getValue();
141 if (!type.equals("application/json") &&
142 !type.startsWith("application/json;")) {
143 Logger.warn(LOG_TAG, "Got non-JSON response with Content-Type " +
144 contentType + ". Misconfigured server?");
145 throw new TokenServerMalformedResponseException(null, "Non-JSON response Content-Type.");
146 }
147
148 // Responses should *always* be a valid JSON object.
149 // It turns out that right now they're not always, but that's a server bug...
150 ExtendedJSONObject result;
151 try {
152 result = res.jsonObjectBody();
153 } catch (Exception e) {
154 Logger.debug(LOG_TAG, "Malformed token response.", e);
155 throw new TokenServerMalformedResponseException(null, e);
156 }
157
158 // The service shouldn't have any 3xx, so we don't need to handle those.
159 if (res.getStatusCode() != 200) {
160 // We should have a (Cornice) error report in the JSON. We log that to
161 // help with debugging.
162 List<ExtendedJSONObject> errorList = new ArrayList<ExtendedJSONObject>();
163
164 if (result.containsKey(JSON_KEY_ERRORS)) {
165 try {
166 for (Object error : result.getArray(JSON_KEY_ERRORS)) {
167 Logger.warn(LOG_TAG, "" + error);
168
169 if (error instanceof JSONObject) {
170 errorList.add(new ExtendedJSONObject((JSONObject) error));
171 }
172 }
173 } catch (NonArrayJSONException e) {
174 Logger.warn(LOG_TAG, "Got non-JSON array '" + result.getString(JSON_KEY_ERRORS) + "'.", e);
175 }
176 }
177
178 if (statusCode == 400) {
179 throw new TokenServerMalformedRequestException(errorList, result.toJSONString());
180 }
181
182 if (statusCode == 401) {
183 throw new TokenServerInvalidCredentialsException(errorList, result.toJSONString());
184 }
185
186 // 403 should represent a "condition acceptance needed" response.
187 //
188 // The extra validation of "urls" is important. We don't want to signal
189 // conditions required unless we are absolutely sure that is what the
190 // server is asking for.
191 if (statusCode == 403) {
192 // Bug 792674 and Bug 783598: make this testing simpler. For now, we
193 // check that errors is an array, and take any condition_urls from the
194 // first element.
195
196 try {
197 if (errorList == null || errorList.isEmpty()) {
198 throw new TokenServerMalformedResponseException(errorList, "403 response without proper fields.");
199 }
200
201 ExtendedJSONObject error = errorList.get(0);
202
203 ExtendedJSONObject condition_urls = error.getObject(JSON_KEY_CONDITION_URLS);
204 if (condition_urls != null) {
205 throw new TokenServerConditionsRequiredException(condition_urls);
206 }
207 } catch (NonObjectJSONException e) {
208 Logger.warn(LOG_TAG, "Got non-JSON error object.");
209 }
210
211 throw new TokenServerMalformedResponseException(errorList, "403 response without proper fields.");
212 }
213
214 if (statusCode == 404) {
215 throw new TokenServerUnknownServiceException(errorList);
216 }
217
218 // We shouldn't ever get here...
219 throw new TokenServerException(errorList);
220 }
221
222 try {
223 result.throwIfFieldsMissingOrMisTyped(new String[] { JSON_KEY_ID, JSON_KEY_KEY, JSON_KEY_API_ENDPOINT }, String.class);
224 result.throwIfFieldsMissingOrMisTyped(new String[] { JSON_KEY_UID }, Long.class);
225 } catch (BadRequiredFieldJSONException e ) {
226 throw new TokenServerMalformedResponseException(null, e);
227 }
228
229 Logger.debug(LOG_TAG, "Successful token response: " + result.getString(JSON_KEY_ID));
230
231 return new TokenServerToken(result.getString(JSON_KEY_ID),
232 result.getString(JSON_KEY_KEY),
233 result.get(JSON_KEY_UID).toString(),
234 result.getString(JSON_KEY_API_ENDPOINT));
235 }
236
237 public static class TokenFetchResourceDelegate extends BaseResourceDelegate {
238 private final TokenServerClient client;
239 private final TokenServerClientDelegate delegate;
240 private final String assertion;
241 private final String clientState;
242 private final BaseResource resource;
243 private final boolean conditionsAccepted;
244
245 public TokenFetchResourceDelegate(TokenServerClient client,
246 BaseResource resource,
247 TokenServerClientDelegate delegate,
248 String assertion, String clientState,
249 boolean conditionsAccepted) {
250 super(resource);
251 this.client = client;
252 this.delegate = delegate;
253 this.assertion = assertion;
254 this.clientState = clientState;
255 this.resource = resource;
256 this.conditionsAccepted = conditionsAccepted;
257 }
258
259 @Override
260 public String getUserAgent() {
261 return delegate.getUserAgent();
262 }
263
264 @Override
265 public void handleHttpResponse(HttpResponse response) {
266 // Skew.
267 SkewHandler skewHandler = SkewHandler.getSkewHandlerForResource(resource);
268 skewHandler.updateSkew(response, System.currentTimeMillis());
269
270 // Extract backoff regardless of whether this was an error response, and
271 // Retry-After for 503 responses. The error will be handled elsewhere.)
272 SyncResponse res = new SyncResponse(response);
273 final boolean includeRetryAfter = res.getStatusCode() == 503;
274 int backoffInSeconds = res.totalBackoffInSeconds(includeRetryAfter);
275 if (backoffInSeconds > -1) {
276 client.notifyBackoff(delegate, backoffInSeconds);
277 }
278
279 try {
280 TokenServerToken token = client.processResponse(res);
281 client.invokeHandleSuccess(delegate, token);
282 } catch (TokenServerException e) {
283 client.invokeHandleFailure(delegate, e);
284 }
285 }
286
287 @Override
288 public void handleTransportException(GeneralSecurityException e) {
289 client.invokeHandleError(delegate, e);
290 }
291
292 @Override
293 public void handleHttpProtocolException(ClientProtocolException e) {
294 client.invokeHandleError(delegate, e);
295 }
296
297 @Override
298 public void handleHttpIOException(IOException e) {
299 client.invokeHandleError(delegate, e);
300 }
301
302 @Override
303 public AuthHeaderProvider getAuthHeaderProvider() {
304 return new BrowserIDAuthHeaderProvider(assertion);
305 }
306
307 @Override
308 public void addHeaders(HttpRequestBase request, DefaultHttpClient client) {
309 String host = request.getURI().getHost();
310 request.setHeader(new BasicHeader(HttpHeaders.HOST, host));
311 if (clientState != null) {
312 request.setHeader(new BasicHeader(HEADER_CLIENT_STATE, clientState));
313 }
314 if (conditionsAccepted) {
315 request.addHeader(HEADER_CONDITIONS_ACCEPTED, "1");
316 }
317 }
318 }
319
320 public void getTokenFromBrowserIDAssertion(final String assertion,
321 final boolean conditionsAccepted,
322 final String clientState,
323 final TokenServerClientDelegate delegate) {
324 final BaseResource resource = new BaseResource(this.uri);
325 resource.delegate = new TokenFetchResourceDelegate(this, resource, delegate,
326 assertion, clientState,
327 conditionsAccepted);
328 resource.get();
329 }
330 }

mercurial