Bug 1852754: part 9) Add tests for dynamically loading <link rel="prefetch"> elements...
[gecko.git] / services / common / tokenserverclient.sys.mjs
blobc23e3d779c7b745ecd9a270ab87eff6acf2f57a9
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 file,
3  * You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { Log } from "resource://gre/modules/Log.sys.mjs";
7 import { RESTRequest } from "resource://services-common/rest.sys.mjs";
8 import { Observers } from "resource://services-common/observers.sys.mjs";
10 const PREF_LOG_LEVEL = "services.common.log.logger.tokenserverclient";
12 /**
13  * Represents a TokenServerClient error that occurred on the client.
14  *
15  * This is the base type for all errors raised by client operations.
16  *
17  * @param message
18  *        (string) Error message.
19  */
20 export function TokenServerClientError(message) {
21   this.name = "TokenServerClientError";
22   this.message = message || "Client error.";
23   // Without explicitly setting .stack, all stacks from these errors will point
24   // to the "new Error()" call a few lines down, which isn't helpful.
25   this.stack = Error().stack;
28 TokenServerClientError.prototype = new Error();
29 TokenServerClientError.prototype.constructor = TokenServerClientError;
30 TokenServerClientError.prototype._toStringFields = function () {
31   return { message: this.message };
33 TokenServerClientError.prototype.toString = function () {
34   return this.name + "(" + JSON.stringify(this._toStringFields()) + ")";
36 TokenServerClientError.prototype.toJSON = function () {
37   let result = this._toStringFields();
38   result.name = this.name;
39   return result;
42 /**
43  * Represents a TokenServerClient error that occurred in the network layer.
44  *
45  * @param error
46  *        The underlying error thrown by the network layer.
47  */
48 export function TokenServerClientNetworkError(error) {
49   this.name = "TokenServerClientNetworkError";
50   this.error = error;
51   this.stack = Error().stack;
54 TokenServerClientNetworkError.prototype = new TokenServerClientError();
55 TokenServerClientNetworkError.prototype.constructor =
56   TokenServerClientNetworkError;
57 TokenServerClientNetworkError.prototype._toStringFields = function () {
58   return { error: this.error };
61 /**
62  * Represents a TokenServerClient error that occurred on the server.
63  *
64  * This type will be encountered for all non-200 response codes from the
65  * server. The type of error is strongly enumerated and is stored in the
66  * `cause` property. This property can have the following string values:
67  *
68  *   invalid-credentials -- A token could not be obtained because
69  *     the credentials presented by the client were invalid.
70  *
71  *   unknown-service -- The requested service was not found.
72  *
73  *   malformed-request -- The server rejected the request because it
74  *     was invalid. If you see this, code in this file is likely wrong.
75  *
76  *   malformed-response -- The response from the server was not what was
77  *     expected.
78  *
79  *   general -- A general server error has occurred. Clients should
80  *     interpret this as an opaque failure.
81  *
82  * @param message
83  *        (string) Error message.
84  */
85 export function TokenServerClientServerError(message, cause = "general") {
86   this.now = new Date().toISOString(); // may be useful to diagnose time-skew issues.
87   this.name = "TokenServerClientServerError";
88   this.message = message || "Server error.";
89   this.cause = cause;
90   this.stack = Error().stack;
93 TokenServerClientServerError.prototype = new TokenServerClientError();
94 TokenServerClientServerError.prototype.constructor =
95   TokenServerClientServerError;
97 TokenServerClientServerError.prototype._toStringFields = function () {
98   let fields = {
99     now: this.now,
100     message: this.message,
101     cause: this.cause,
102   };
103   if (this.response) {
104     fields.response_body = this.response.body;
105     fields.response_headers = this.response.headers;
106     fields.response_status = this.response.status;
107   }
108   return fields;
112  * Represents a client to the Token Server.
114  * http://docs.services.mozilla.com/token/index.html
116  * The Token Server was designed to support obtaining tokens for arbitrary apps by
117  * constructing URI paths of the form <app>/<app_version>. In practice this was
118  * never used and it only supports an <app> value of `sync`, and the API presented
119  * here reflects that.
121  * Areas to Improve:
123  *  - The server sends a JSON response on error. The client does not currently
124  *    parse this. It might be convenient if it did.
125  *  - Currently most non-200 status codes are rolled into one error type. It
126  *    might be helpful if callers had a richer API that communicated who was
127  *    at fault (e.g. differentiating a 503 from a 401).
128  */
129 export function TokenServerClient() {
130   this._log = Log.repository.getLogger("Services.Common.TokenServerClient");
131   this._log.manageLevelFromPref(PREF_LOG_LEVEL);
134 TokenServerClient.prototype = {
135   /**
136    * Logger instance.
137    */
138   _log: null,
140   /**
141    * Obtain a token from a provided OAuth token against a specific URL.
142    *
143    * This asynchronously obtains the token.
144    * It returns a Promise that resolves or rejects:
145    *
146    *  Rejects with:
147    *   (TokenServerClientError) If no token could be obtained, this
148    *     will be a TokenServerClientError instance describing why. The
149    *     type seen defines the type of error encountered. If an HTTP response
150    *     was seen, a RESTResponse instance will be stored in the `response`
151    *     property of this object. If there was no error and a token is
152    *     available, this will be null.
153    *
154    *  Resolves with:
155    *   (map) On success, this will be a map containing the results from
156    *     the server. If there was an error, this will be null. The map has the
157    *     following properties:
158    *
159    *       id       (string) HTTP MAC public key identifier.
160    *       key      (string) HTTP MAC shared symmetric key.
161    *       endpoint (string) URL where service can be connected to.
162    *       uid      (string) user ID for requested service.
163    *       duration (string) the validity duration of the issued token.
164    *
165    * Example Usage
166    * -------------
167    *
168    *   let client = new TokenServerClient();
169    *   let access_token = getOAuthAccessTokenFromSomewhere();
170    *   let url = "https://token.services.mozilla.com/1.0/sync/2.0";
171    *
172    *   try {
173    *     const result = await client.getTokenUsingOAuth(url, access_token);
174    *     let {id, key, uid, endpoint, duration} = result;
175    *     // Do stuff with data and carry on.
176    *   } catch (error) {
177    *     // Handle errors.
178    *   }
179    * Obtain a token from a provided OAuth token against a specific URL.
180    *
181    * @param  url
182    *         (string) URL to fetch token from.
183    * @param  oauthToken
184    *         (string) FxA OAuth Token to exchange token for.
185    * @param  addHeaders
186    *         (object) Extra headers for the request.
187    */
188   async getTokenUsingOAuth(url, oauthToken, addHeaders = {}) {
189     this._log.debug("Beginning OAuth token exchange: " + url);
191     if (!oauthToken) {
192       throw new TokenServerClientError("oauthToken argument is not valid.");
193     }
195     return this._tokenServerExchangeRequest(
196       url,
197       `Bearer ${oauthToken}`,
198       addHeaders
199     );
200   },
202   /**
203    * Performs the exchange request to the token server to
204    * produce a token based on the authorizationHeader input.
205    *
206    * @param  url
207    *         (string) URL to fetch token from.
208    * @param  authorizationHeader
209    *         (string) The auth header string that populates the 'Authorization' header.
210    * @param  addHeaders
211    *         (object) Extra headers for the request.
212    */
213   async _tokenServerExchangeRequest(url, authorizationHeader, addHeaders = {}) {
214     if (!url) {
215       throw new TokenServerClientError("url argument is not valid.");
216     }
218     if (!authorizationHeader) {
219       throw new TokenServerClientError(
220         "authorizationHeader argument is not valid."
221       );
222     }
224     let req = this.newRESTRequest(url);
225     req.setHeader("Accept", "application/json");
226     req.setHeader("Authorization", authorizationHeader);
228     for (let header in addHeaders) {
229       req.setHeader(header, addHeaders[header]);
230     }
231     let response;
232     try {
233       response = await req.get();
234     } catch (err) {
235       throw new TokenServerClientNetworkError(err);
236     }
238     try {
239       return this._processTokenResponse(response);
240     } catch (ex) {
241       if (ex instanceof TokenServerClientServerError) {
242         throw ex;
243       }
244       this._log.warn("Error processing token server response", ex);
245       let error = new TokenServerClientError(ex);
246       error.response = response;
247       throw error;
248     }
249   },
251   /**
252    * Handler to process token request responses.
253    *
254    * @param response
255    *        RESTResponse from token HTTP request.
256    */
257   _processTokenResponse(response) {
258     this._log.debug("Got token response: " + response.status);
260     // Responses should *always* be JSON, even in the case of 4xx and 5xx
261     // errors. If we don't see JSON, the server is likely very unhappy.
262     let ct = response.headers["content-type"] || "";
263     if (ct != "application/json" && !ct.startsWith("application/json;")) {
264       this._log.warn("Did not receive JSON response. Misconfigured server?");
265       this._log.debug("Content-Type: " + ct);
266       this._log.debug("Body: " + response.body);
268       let error = new TokenServerClientServerError(
269         "Non-JSON response.",
270         "malformed-response"
271       );
272       error.response = response;
273       throw error;
274     }
276     let result;
277     try {
278       result = JSON.parse(response.body);
279     } catch (ex) {
280       this._log.warn("Invalid JSON returned by server: " + response.body);
281       let error = new TokenServerClientServerError(
282         "Malformed JSON.",
283         "malformed-response"
284       );
285       error.response = response;
286       throw error;
287     }
289     // Any response status can have X-Backoff or X-Weave-Backoff headers.
290     this._maybeNotifyBackoff(response, "x-weave-backoff");
291     this._maybeNotifyBackoff(response, "x-backoff");
293     // The service shouldn't have any 3xx, so we don't need to handle those.
294     if (response.status != 200) {
295       // We /should/ have a Cornice error report in the JSON. We log that to
296       // help with debugging.
297       if ("errors" in result) {
298         // This could throw, but this entire function is wrapped in a try. If
299         // the server is sending something not an array of objects, it has
300         // failed to keep its contract with us and there is little we can do.
301         for (let error of result.errors) {
302           this._log.info("Server-reported error: " + JSON.stringify(error));
303         }
304       }
306       let error = new TokenServerClientServerError();
307       error.response = response;
309       if (response.status == 400) {
310         error.message = "Malformed request.";
311         error.cause = "malformed-request";
312       } else if (response.status == 401) {
313         // Cause can be invalid-credentials, invalid-timestamp, or
314         // invalid-generation.
315         error.message = "Authentication failed.";
316         error.cause = result.status;
317       } else if (response.status == 404) {
318         error.message = "Unknown service.";
319         error.cause = "unknown-service";
320       }
322       // A Retry-After header should theoretically only appear on a 503, but
323       // we'll look for it on any error response.
324       this._maybeNotifyBackoff(response, "retry-after");
326       throw error;
327     }
329     for (let k of ["id", "key", "api_endpoint", "uid", "duration"]) {
330       if (!(k in result)) {
331         let error = new TokenServerClientServerError(
332           "Expected key not present in result: " + k
333         );
334         error.cause = "malformed-response";
335         error.response = response;
336         throw error;
337       }
338     }
340     this._log.debug("Successful token response");
341     return {
342       id: result.id,
343       key: result.key,
344       endpoint: result.api_endpoint,
345       uid: result.uid,
346       duration: result.duration,
347       hashed_fxa_uid: result.hashed_fxa_uid,
348       node_type: result.node_type,
349     };
350   },
352   /*
353    * The prefix used for all notifications sent by this module.  This
354    * allows the handler of notifications to be sure they are handling
355    * notifications for the service they expect.
356    *
357    * If not set, no notifications will be sent.
358    */
359   observerPrefix: null,
361   // Given an optional header value, notify that a backoff has been requested.
362   _maybeNotifyBackoff(response, headerName) {
363     if (!this.observerPrefix) {
364       return;
365     }
366     let headerVal = response.headers[headerName];
367     if (!headerVal) {
368       return;
369     }
370     let backoffInterval;
371     try {
372       backoffInterval = parseInt(headerVal, 10);
373     } catch (ex) {
374       this._log.error(
375         "TokenServer response had invalid backoff value in '" +
376           headerName +
377           "' header: " +
378           headerVal
379       );
380       return;
381     }
382     Observers.notify(
383       this.observerPrefix + ":backoff:interval",
384       backoffInterval
385     );
386   },
388   // override points for testing.
389   newRESTRequest(url) {
390     return new RESTRequest(url);
391   },