summaryrefslogtreecommitdiffstats
path: root/services/common/tokenserverclient.sys.mjs
blob: c23e3d779c7b745ecd9a270ab87eff6acf2f57a9 (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
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
/* 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/. */

import { Log } from "resource://gre/modules/Log.sys.mjs";

import { RESTRequest } from "resource://services-common/rest.sys.mjs";
import { Observers } from "resource://services-common/observers.sys.mjs";

const PREF_LOG_LEVEL = "services.common.log.logger.tokenserverclient";

/**
 * Represents a TokenServerClient error that occurred on the client.
 *
 * This is the base type for all errors raised by client operations.
 *
 * @param message
 *        (string) Error message.
 */
export function TokenServerClientError(message) {
  this.name = "TokenServerClientError";
  this.message = message || "Client error.";
  // Without explicitly setting .stack, all stacks from these errors will point
  // to the "new Error()" call a few lines down, which isn't helpful.
  this.stack = Error().stack;
}

TokenServerClientError.prototype = new Error();
TokenServerClientError.prototype.constructor = TokenServerClientError;
TokenServerClientError.prototype._toStringFields = function () {
  return { message: this.message };
};
TokenServerClientError.prototype.toString = function () {
  return this.name + "(" + JSON.stringify(this._toStringFields()) + ")";
};
TokenServerClientError.prototype.toJSON = function () {
  let result = this._toStringFields();
  result.name = this.name;
  return result;
};

/**
 * Represents a TokenServerClient error that occurred in the network layer.
 *
 * @param error
 *        The underlying error thrown by the network layer.
 */
export function TokenServerClientNetworkError(error) {
  this.name = "TokenServerClientNetworkError";
  this.error = error;
  this.stack = Error().stack;
}

TokenServerClientNetworkError.prototype = new TokenServerClientError();
TokenServerClientNetworkError.prototype.constructor =
  TokenServerClientNetworkError;
TokenServerClientNetworkError.prototype._toStringFields = function () {
  return { error: this.error };
};

/**
 * Represents a TokenServerClient error that occurred on the server.
 *
 * This type will be encountered for all non-200 response codes from the
 * server. The type of error is strongly enumerated and is stored in the
 * `cause` property. This property can have the following string values:
 *
 *   invalid-credentials -- A token could not be obtained because
 *     the credentials presented by the client were invalid.
 *
 *   unknown-service -- The requested service was not found.
 *
 *   malformed-request -- The server rejected the request because it
 *     was invalid. If you see this, code in this file is likely wrong.
 *
 *   malformed-response -- The response from the server was not what was
 *     expected.
 *
 *   general -- A general server error has occurred. Clients should
 *     interpret this as an opaque failure.
 *
 * @param message
 *        (string) Error message.
 */
export function TokenServerClientServerError(message, cause = "general") {
  this.now = new Date().toISOString(); // may be useful to diagnose time-skew issues.
  this.name = "TokenServerClientServerError";
  this.message = message || "Server error.";
  this.cause = cause;
  this.stack = Error().stack;
}

TokenServerClientServerError.prototype = new TokenServerClientError();
TokenServerClientServerError.prototype.constructor =
  TokenServerClientServerError;

TokenServerClientServerError.prototype._toStringFields = function () {
  let fields = {
    now: this.now,
    message: this.message,
    cause: this.cause,
  };
  if (this.response) {
    fields.response_body = this.response.body;
    fields.response_headers = this.response.headers;
    fields.response_status = this.response.status;
  }
  return fields;
};

/**
 * Represents a client to the Token Server.
 *
 * http://docs.services.mozilla.com/token/index.html
 *
 * The Token Server was designed to support obtaining tokens for arbitrary apps by
 * constructing URI paths of the form <app>/<app_version>. In practice this was
 * never used and it only supports an <app> value of `sync`, and the API presented
 * here reflects that.
 *
 * Areas to Improve:
 *
 *  - The server sends a JSON response on error. The client does not currently
 *    parse this. It might be convenient if it did.
 *  - Currently most non-200 status codes are rolled into one error type. It
 *    might be helpful if callers had a richer API that communicated who was
 *    at fault (e.g. differentiating a 503 from a 401).
 */
export function TokenServerClient() {
  this._log = Log.repository.getLogger("Services.Common.TokenServerClient");
  this._log.manageLevelFromPref(PREF_LOG_LEVEL);
}

TokenServerClient.prototype = {
  /**
   * Logger instance.
   */
  _log: null,

  /**
   * Obtain a token from a provided OAuth token against a specific URL.
   *
   * This asynchronously obtains the token.
   * It returns a Promise that resolves or rejects:
   *
   *  Rejects with:
   *   (TokenServerClientError) If no token could be obtained, this
   *     will be a TokenServerClientError instance describing why. The
   *     type seen defines the type of error encountered. If an HTTP response
   *     was seen, a RESTResponse instance will be stored in the `response`
   *     property of this object. If there was no error and a token is
   *     available, this will be null.
   *
   *  Resolves with:
   *   (map) On success, this will be a map containing the results from
   *     the server. If there was an error, this will be null. The map has the
   *     following properties:
   *
   *       id       (string) HTTP MAC public key identifier.
   *       key      (string) HTTP MAC shared symmetric key.
   *       endpoint (string) URL where service can be connected to.
   *       uid      (string) user ID for requested service.
   *       duration (string) the validity duration of the issued token.
   *
   * Example Usage
   * -------------
   *
   *   let client = new TokenServerClient();
   *   let access_token = getOAuthAccessTokenFromSomewhere();
   *   let url = "https://token.services.mozilla.com/1.0/sync/2.0";
   *
   *   try {
   *     const result = await client.getTokenUsingOAuth(url, access_token);
   *     let {id, key, uid, endpoint, duration} = result;
   *     // Do stuff with data and carry on.
   *   } catch (error) {
   *     // Handle errors.
   *   }
   * Obtain a token from a provided OAuth token against a specific URL.
   *
   * @param  url
   *         (string) URL to fetch token from.
   * @param  oauthToken
   *         (string) FxA OAuth Token to exchange token for.
   * @param  addHeaders
   *         (object) Extra headers for the request.
   */
  async getTokenUsingOAuth(url, oauthToken, addHeaders = {}) {
    this._log.debug("Beginning OAuth token exchange: " + url);

    if (!oauthToken) {
      throw new TokenServerClientError("oauthToken argument is not valid.");
    }

    return this._tokenServerExchangeRequest(
      url,
      `Bearer ${oauthToken}`,
      addHeaders
    );
  },

  /**
   * Performs the exchange request to the token server to
   * produce a token based on the authorizationHeader input.
   *
   * @param  url
   *         (string) URL to fetch token from.
   * @param  authorizationHeader
   *         (string) The auth header string that populates the 'Authorization' header.
   * @param  addHeaders
   *         (object) Extra headers for the request.
   */
  async _tokenServerExchangeRequest(url, authorizationHeader, addHeaders = {}) {
    if (!url) {
      throw new TokenServerClientError("url argument is not valid.");
    }

    if (!authorizationHeader) {
      throw new TokenServerClientError(
        "authorizationHeader argument is not valid."
      );
    }

    let req = this.newRESTRequest(url);
    req.setHeader("Accept", "application/json");
    req.setHeader("Authorization", authorizationHeader);

    for (let header in addHeaders) {
      req.setHeader(header, addHeaders[header]);
    }
    let response;
    try {
      response = await req.get();
    } catch (err) {
      throw new TokenServerClientNetworkError(err);
    }

    try {
      return this._processTokenResponse(response);
    } catch (ex) {
      if (ex instanceof TokenServerClientServerError) {
        throw ex;
      }
      this._log.warn("Error processing token server response", ex);
      let error = new TokenServerClientError(ex);
      error.response = response;
      throw error;
    }
  },

  /**
   * Handler to process token request responses.
   *
   * @param response
   *        RESTResponse from token HTTP request.
   */
  _processTokenResponse(response) {
    this._log.debug("Got token response: " + response.status);

    // 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.
    let ct = response.headers["content-type"] || "";
    if (ct != "application/json" && !ct.startsWith("application/json;")) {
      this._log.warn("Did not receive JSON response. Misconfigured server?");
      this._log.debug("Content-Type: " + ct);
      this._log.debug("Body: " + response.body);

      let error = new TokenServerClientServerError(
        "Non-JSON response.",
        "malformed-response"
      );
      error.response = response;
      throw error;
    }

    let result;
    try {
      result = JSON.parse(response.body);
    } catch (ex) {
      this._log.warn("Invalid JSON returned by server: " + response.body);
      let error = new TokenServerClientServerError(
        "Malformed JSON.",
        "malformed-response"
      );
      error.response = response;
      throw error;
    }

    // Any response status can have X-Backoff or X-Weave-Backoff headers.
    this._maybeNotifyBackoff(response, "x-weave-backoff");
    this._maybeNotifyBackoff(response, "x-backoff");

    // The service shouldn't have any 3xx, so we don't need to handle those.
    if (response.status != 200) {
      // We /should/ have a Cornice error report in the JSON. We log that to
      // help with debugging.
      if ("errors" in result) {
        // This could throw, but this entire function is wrapped in a try. If
        // the server is sending something not an array of objects, it has
        // failed to keep its contract with us and there is little we can do.
        for (let error of result.errors) {
          this._log.info("Server-reported error: " + JSON.stringify(error));
        }
      }

      let error = new TokenServerClientServerError();
      error.response = response;

      if (response.status == 400) {
        error.message = "Malformed request.";
        error.cause = "malformed-request";
      } else if (response.status == 401) {
        // Cause can be invalid-credentials, invalid-timestamp, or
        // invalid-generation.
        error.message = "Authentication failed.";
        error.cause = result.status;
      } else if (response.status == 404) {
        error.message = "Unknown service.";
        error.cause = "unknown-service";
      }

      // A Retry-After header should theoretically only appear on a 503, but
      // we'll look for it on any error response.
      this._maybeNotifyBackoff(response, "retry-after");

      throw error;
    }

    for (let k of ["id", "key", "api_endpoint", "uid", "duration"]) {
      if (!(k in result)) {
        let error = new TokenServerClientServerError(
          "Expected key not present in result: " + k
        );
        error.cause = "malformed-response";
        error.response = response;
        throw error;
      }
    }

    this._log.debug("Successful token response");
    return {
      id: result.id,
      key: result.key,
      endpoint: result.api_endpoint,
      uid: result.uid,
      duration: result.duration,
      hashed_fxa_uid: result.hashed_fxa_uid,
      node_type: result.node_type,
    };
  },

  /*
   * The prefix used for all notifications sent by this module.  This
   * allows the handler of notifications to be sure they are handling
   * notifications for the service they expect.
   *
   * If not set, no notifications will be sent.
   */
  observerPrefix: null,

  // Given an optional header value, notify that a backoff has been requested.
  _maybeNotifyBackoff(response, headerName) {
    if (!this.observerPrefix) {
      return;
    }
    let headerVal = response.headers[headerName];
    if (!headerVal) {
      return;
    }
    let backoffInterval;
    try {
      backoffInterval = parseInt(headerVal, 10);
    } catch (ex) {
      this._log.error(
        "TokenServer response had invalid backoff value in '" +
          headerName +
          "' header: " +
          headerVal
      );
      return;
    }
    Observers.notify(
      this.observerPrefix + ":backoff:interval",
      backoffInterval
    );
  },

  // override points for testing.
  newRESTRequest(url) {
    return new RESTRequest(url);
  },
};