|
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 } |