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