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/. */
5 import { CommonUtils } from "resource://services-common/utils.sys.mjs";
7 import { HawkClient } from "resource://services-common/hawkclient.sys.mjs";
8 import { deriveHawkCredentials } from "resource://services-common/hawkrequest.sys.mjs";
9 import { CryptoUtils } from "resource://services-crypto/utils.sys.mjs";
12 ERRNO_ACCOUNT_DOES_NOT_EXIST,
13 ERRNO_INCORRECT_EMAIL_CASE,
14 ERRNO_INCORRECT_PASSWORD,
15 ERRNO_INVALID_AUTH_NONCE,
16 ERRNO_INVALID_AUTH_TIMESTAMP,
17 ERRNO_INVALID_AUTH_TOKEN,
19 } from "resource://gre/modules/FxAccountsCommon.sys.mjs";
21 import { Credentials } from "resource://gre/modules/Credentials.sys.mjs";
23 const HOST_PREF = "identity.fxaccounts.auth.uri";
25 const SIGNIN = "/account/login";
26 const SIGNUP = "/account/create";
27 // Devices older than this many days will not appear in the devices list
28 const DEVICES_FILTER_DAYS = 21;
30 export var FxAccountsClient = function (
31 host = Services.prefs.getCharPref(HOST_PREF)
35 // The FxA auth server expects requests to certain endpoints to be authorized
37 this.hawk = new HawkClient(host);
38 this.hawk.observerPrefix = "FxA:hawk";
40 // Manage server backoff state. C.f.
41 // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#backoff-protocol
42 this.backoffError = null;
45 FxAccountsClient.prototype = {
47 * Return client clock offset, in milliseconds, as determined by hawk client.
48 * Provided because callers should not have to know about hawk
51 * The offset is the number of milliseconds that must be added to the client
52 * clock to make it equal to the server clock. For example, if the client is
53 * five minutes ahead of the server, the localtimeOffsetMsec will be -300000.
55 get localtimeOffsetMsec() {
56 return this.hawk.localtimeOffsetMsec;
60 * Return current time in milliseconds
62 * Not used by this module, but made available to the FxAccounts.jsm
63 * that uses this client.
66 return this.hawk.now();
70 * Common code from signIn and signUp.
73 * Request URL path. Can be /account/create or /account/login
75 * The email address for the account (utf8)
78 * @param [getKeys=false]
79 * If set to true the keyFetchToken will be retrieved
80 * @param [retryOK=true]
81 * If capitalization of the email is wrong and retryOK is set to true,
82 * we will retry with the suggested capitalization from the server
84 * Returns a promise that resolves to an object:
86 * authAt: authentication time for the session (seconds since epoch)
87 * email: the primary email for this account
88 * keyFetchToken: a key fetch token (hex)
89 * sessionToken: a session token (hex)
90 * uid: the user's unique ID (hex)
91 * unwrapBKey: used to unwrap kB, derived locally from the
92 * password (not revealed to the FxA server)
93 * verified (optional): flag indicating verification status of the
97 _createSession(path, email, password, getKeys = false, retryOK = true) {
98 return Credentials.setup(email, password).then(creds => {
100 authPW: CommonUtils.bytesAsHex(creds.authPW),
103 let keys = getKeys ? "?keys=true" : "";
105 return this._request(path + keys, "POST", null, data).then(
106 // Include the canonical capitalization of the email in the response so
107 // the caller can set its signed-in user state accordingly.
109 result.email = data.email;
110 result.unwrapBKey = CommonUtils.bytesAsHex(creds.unwrapBKey);
115 log.debug("Session creation failed", error);
116 // If the user entered an email with different capitalization from
117 // what's stored in the database (e.g., Greta.Garbo@gmail.COM as
118 // opposed to greta.garbo@gmail.com), the server will respond with a
119 // errno 120 (code 400) and the expected capitalization of the email.
120 // We retry with this email exactly once. If successful, we use the
121 // server's version of the email as the signed-in-user's email. This
122 // is necessary because the email also serves as salt; so we must be
123 // in agreement with the server on capitalization.
126 // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md
127 if (ERRNO_INCORRECT_EMAIL_CASE === error.errno && retryOK) {
129 log.error("Server returned errno 120 but did not provide email");
132 return this._createSession(
147 * Create a new Firefox Account and authenticate
150 * The email address for the account (utf8)
152 * The user's password
153 * @param [getKeys=false]
154 * If set to true the keyFetchToken will be retrieved
156 * Returns a promise that resolves to an object:
158 * uid: the user's unique ID (hex)
159 * sessionToken: a session token (hex)
160 * keyFetchToken: a key fetch token (hex),
161 * unwrapBKey: used to unwrap kB, derived locally from the
162 * password (not revealed to the FxA server)
165 signUp(email, password, getKeys = false) {
166 return this._createSession(
176 * Authenticate and create a new session with the Firefox Account API server
179 * The email address for the account (utf8)
181 * The user's password
182 * @param [getKeys=false]
183 * If set to true the keyFetchToken will be retrieved
185 * Returns a promise that resolves to an object:
187 * authAt: authentication time for the session (seconds since epoch)
188 * email: the primary email for this account
189 * keyFetchToken: a key fetch token (hex)
190 * sessionToken: a session token (hex)
191 * uid: the user's unique ID (hex)
192 * unwrapBKey: used to unwrap kB, derived locally from the
193 * password (not revealed to the FxA server)
194 * verified: flag indicating verification status of the email
197 signIn: function signIn(email, password, getKeys = false) {
198 return this._createSession(
208 * Check the status of a session given a session token
210 * @param sessionTokenHex
211 * The session token encoded in hex
213 * Resolves with a boolean indicating if the session is still valid
215 async sessionStatus(sessionTokenHex) {
216 const credentials = await deriveHawkCredentials(
220 return this._request("/session/status", "GET", credentials).then(
221 () => Promise.resolve(true),
223 if (isInvalidTokenError(error)) {
224 return Promise.resolve(false);
232 * List all the clients connected to the authenticated user's account,
233 * including devices, OAuth clients, and web sessions.
235 * @param sessionTokenHex
236 * The session token encoded in hex
239 async attachedClients(sessionTokenHex) {
240 const credentials = await deriveHawkCredentials(
244 return this._requestWithHeaders(
245 "/account/attached_clients",
252 * Retrieves an OAuth authorization code.
254 * @param String sessionTokenHex
255 * The session token encoded in hex
256 * @param {Object} options
257 * @param options.client_id
258 * @param options.state
259 * @param options.scope
260 * @param options.access_type
261 * @param options.code_challenge_method
262 * @param options.code_challenge
263 * @param [options.keys_jwe]
264 * @returns {Promise<Object>} Object containing `code` and `state`.
266 async oauthAuthorize(sessionTokenHex, options) {
267 const credentials = await deriveHawkCredentials(
272 client_id: options.client_id,
273 response_type: "code",
274 state: options.state,
275 scope: options.scope,
276 access_type: options.access_type,
277 code_challenge: options.code_challenge,
278 code_challenge_method: options.code_challenge_method,
280 if (options.keys_jwe) {
281 body.keys_jwe = options.keys_jwe;
283 return this._request("/oauth/authorization", "POST", credentials, body);
287 * Destroy an OAuth access token or refresh token.
289 * @param String clientId
290 * @param String token The token to be revoked.
292 async oauthDestroy(clientId, token) {
297 return this._request("/oauth/destroy", "POST", null, body);
301 * Query for the information required to derive
302 * scoped encryption keys requested by the specified OAuth client.
304 * @param sessionTokenHex
305 * The session token encoded in hex
308 * Space separated list of scopes
311 async getScopedKeyData(sessionTokenHex, clientId, scope) {
313 throw new Error("Missing 'clientId' parameter");
316 throw new Error("Missing 'scope' parameter");
322 const credentials = await deriveHawkCredentials(
326 return this._request(
327 "/account/scoped-key-data",
335 * Destroy the current session with the Firefox Account API server and its
338 * @param sessionTokenHex
339 * The session token encoded in hex
342 async signOut(sessionTokenHex, options = {}) {
343 const credentials = await deriveHawkCredentials(
347 let path = "/session/destroy";
348 if (options.service) {
349 path += "?service=" + encodeURIComponent(options.service);
351 return this._request(path, "POST", credentials);
355 * Check the verification status of the user's FxA email address
357 * @param sessionTokenHex
358 * The current session token encoded in hex
361 async recoveryEmailStatus(sessionTokenHex, options = {}) {
362 const credentials = await deriveHawkCredentials(
366 let path = "/recovery_email/status";
367 if (options.reason) {
368 path += "?reason=" + encodeURIComponent(options.reason);
371 return this._request(path, "GET", credentials);
375 * Resend the verification email for the user
377 * @param sessionTokenHex
378 * The current token encoded in hex
381 async resendVerificationEmail(sessionTokenHex) {
382 const credentials = await deriveHawkCredentials(
386 return this._request("/recovery_email/resend_code", "POST", credentials);
390 * Retrieve encryption keys
392 * @param keyFetchTokenHex
393 * A one-time use key fetch token encoded in hex
395 * Returns a promise that resolves to an object:
397 * kA: an encryption key for recevorable data (bytes)
398 * wrapKB: an encryption key that requires knowledge of the
399 * user's password (bytes)
402 async accountKeys(keyFetchTokenHex) {
403 let creds = await deriveHawkCredentials(keyFetchTokenHex, "keyFetchToken");
404 let keyRequestKey = creds.extra.slice(0, 32);
405 let morecreds = await CryptoUtils.hkdfLegacy(
408 Credentials.keyWord("account/keys"),
411 let respHMACKey = morecreds.slice(0, 32);
412 let respXORKey = morecreds.slice(32, 96);
414 const resp = await this._request("/account/keys", "GET", creds);
416 throw new Error("failed to retrieve keys");
419 let bundle = CommonUtils.hexToBytes(resp.bundle);
420 let mac = bundle.slice(-32);
421 let key = CommonUtils.byteStringToArrayBuffer(respHMACKey);
422 // CryptoUtils.hmac takes ArrayBuffers as inputs for the key and data and
423 // returns an ArrayBuffer.
424 let bundleMAC = await CryptoUtils.hmac(
427 CommonUtils.byteStringToArrayBuffer(bundle.slice(0, -32))
429 if (mac !== CommonUtils.arrayBufferToByteString(bundleMAC)) {
430 throw new Error("error unbundling encryption keys");
433 let keyAWrapB = CryptoUtils.xor(respXORKey, bundle.slice(0, 64));
436 kA: keyAWrapB.slice(0, 32),
437 wrapKB: keyAWrapB.slice(32),
442 * Obtain an OAuth access token by authenticating using a session token.
444 * @param {String} sessionTokenHex
445 * The session token encoded in hex
446 * @param {String} clientId
447 * @param {String} scope
448 * List of space-separated scopes.
449 * @param {Number} ttl
450 * Token time to live.
451 * @return {Promise<Object>} Object containing an `access_token`.
453 async accessTokenWithSessionToken(sessionTokenHex, clientId, scope, ttl) {
454 const credentials = await deriveHawkCredentials(
460 grant_type: "fxa-credentials",
464 return this._request("/oauth/token", "POST", credentials, body);
468 * Determine if an account exists
471 * The email address to check
473 * The promise resolves to true if the account exists, or false
474 * if it doesn't. The promise is rejected on other errors.
476 accountExists(email) {
477 return this.signIn(email, "").then(
479 throw new Error("How did I sign in with an empty password?");
482 switch (expectedError.errno) {
483 case ERRNO_ACCOUNT_DOES_NOT_EXIST:
485 case ERRNO_INCORRECT_PASSWORD:
488 // not so expected, any more ...
496 * Given the uid of an existing account (not an arbitrary email), ask
497 * the server if it still exists via /account/status.
499 * Used for differentiating between password change and account deletion.
502 return this._request("/account/status?uid=" + uid, "GET").then(
504 return result.exists;
507 log.error("accountStatus failed", error);
508 return Promise.reject(error);
514 * Register a new device
516 * @method registerDevice
517 * @param sessionTokenHex
518 * Session token obtained from signIn
522 * Device type (mobile|desktop)
524 * Extra device options
525 * @param [options.availableCommands]
526 * Available commands for this device
527 * @param [options.pushCallback]
528 * `pushCallback` push endpoint callback
529 * @param [options.pushPublicKey]
530 * `pushPublicKey` push public key (URLSafe Base64 string)
531 * @param [options.pushAuthKey]
532 * `pushAuthKey` push auth secret (URLSafe Base64 string)
534 * Resolves to an object:
536 * id: Device identifier
537 * createdAt: Creation time (milliseconds since epoch)
538 * name: Name of device
539 * type: Type of device (mobile|desktop)
542 async registerDevice(sessionTokenHex, name, type, options = {}) {
543 let path = "/account/device";
545 let creds = await deriveHawkCredentials(sessionTokenHex, "sessionToken");
546 let body = { name, type };
548 if (options.pushCallback) {
549 body.pushCallback = options.pushCallback;
551 if (options.pushPublicKey && options.pushAuthKey) {
552 body.pushPublicKey = options.pushPublicKey;
553 body.pushAuthKey = options.pushAuthKey;
555 body.availableCommands = options.availableCommands;
557 return this._request(path, "POST", creds, body);
561 * Sends a message to other devices. Must conform with the push payload schema:
562 * https://github.com/mozilla/fxa-auth-server/blob/master/docs/pushpayloads.schema.json
564 * @method notifyDevice
565 * @param sessionTokenHex
566 * Session token obtained from signIn
568 * Devices to send the message to. If null, will be sent to all devices.
570 * Devices to exclude when sending to all devices (deviceIds must be null).
572 * Data to send with the message
574 * Resolves to an empty object:
584 const credentials = await deriveHawkCredentials(
588 if (deviceIds && excludedIds) {
590 "You cannot specify excluded devices if deviceIds is set."
594 to: deviceIds || "all",
599 body.excluded = excludedIds;
601 return this._request("/account/devices/notify", "POST", credentials, body);
605 * Retrieves pending commands for our device.
607 * @method getCommands
608 * @param sessionTokenHex - Session token obtained from signIn
609 * @param [index] - If specified, only messages received after the one who
610 * had that index will be retrieved.
611 * @param [limit] - Maximum number of messages to retrieve.
613 async getCommands(sessionTokenHex, { index, limit }) {
614 const credentials = await deriveHawkCredentials(
618 const params = new URLSearchParams();
619 if (index != undefined) {
620 params.set("index", index);
622 if (limit != undefined) {
623 params.set("limit", limit);
625 const path = `/account/device/commands?${params.toString()}`;
626 return this._request(path, "GET", credentials);
630 * Invokes a command on another device.
632 * @method invokeCommand
633 * @param sessionTokenHex - Session token obtained from signIn
634 * @param command - Name of the command to invoke
635 * @param target - Recipient device ID.
638 * Resolves to the request's response, (which should be an empty object)
640 async invokeCommand(sessionTokenHex, command, target, payload) {
641 const credentials = await deriveHawkCredentials(
650 return this._request(
651 "/account/devices/invoke_command",
659 * Update the session or name for an existing device
661 * @method updateDevice
662 * @param sessionTokenHex
663 * Session token obtained from signIn
669 * Extra device options
670 * @param [options.availableCommands]
671 * Available commands for this device
672 * @param [options.pushCallback]
673 * `pushCallback` push endpoint callback
674 * @param [options.pushPublicKey]
675 * `pushPublicKey` push public key (URLSafe Base64 string)
676 * @param [options.pushAuthKey]
677 * `pushAuthKey` push auth secret (URLSafe Base64 string)
679 * Resolves to an object:
681 * id: Device identifier
685 async updateDevice(sessionTokenHex, id, name, options = {}) {
686 let path = "/account/device";
688 let creds = await deriveHawkCredentials(sessionTokenHex, "sessionToken");
689 let body = { id, name };
690 if (options.pushCallback) {
691 body.pushCallback = options.pushCallback;
693 if (options.pushPublicKey && options.pushAuthKey) {
694 body.pushPublicKey = options.pushPublicKey;
695 body.pushAuthKey = options.pushAuthKey;
697 body.availableCommands = options.availableCommands;
699 return this._request(path, "POST", creds, body);
703 * Get a list of currently registered devices that have been accessed
704 * in the last `DEVICES_FILTER_DAYS` days
706 * @method getDeviceList
707 * @param sessionTokenHex
708 * Session token obtained from signIn
710 * Resolves to an array of objects:
714 * isCurrentDevice: Boolean indicating whether the item
715 * represents the current device
717 * type: Device type (mobile|desktop)
722 async getDeviceList(sessionTokenHex) {
723 let timestamp = Date.now() - 1000 * 60 * 60 * 24 * DEVICES_FILTER_DAYS;
724 let path = `/account/devices?filterIdleDevicesTimestamp=${timestamp}`;
725 let creds = await deriveHawkCredentials(sessionTokenHex, "sessionToken");
726 return this._request(path, "GET", creds, {});
730 this.backoffError = null;
734 * A general method for sending raw API calls to the FxA auth server.
735 * All request bodies and responses are JSON.
740 * The HTTP request method
746 * Returns a promise that resolves to the JSON response of the API call,
747 * or is rejected with an error. Error responses have the following properties:
749 * "code": 400, // matches the HTTP status code
750 * "errno": 107, // stable application-level error number
751 * "error": "Bad Request", // string description of the error type
752 * "message": "the value of salt is not allowed to be undefined",
753 * "info": "https://docs.dev.lcip.og/errors/1234" // link to more info on the error
756 async _requestWithHeaders(path, method, credentials, jsonPayload) {
757 // We were asked to back off.
758 if (this.backoffError) {
759 log.debug("Received new request during backoff, re-rejecting.");
760 throw this.backoffError;
764 response = await this.hawk.request(
771 log.error(`error ${method}ing ${path}`, error);
772 if (error.retryAfter) {
773 log.debug("Received backoff response; caching error as flag.");
774 this.backoffError = error;
775 // Schedule clearing of cached-error-as-flag.
776 CommonUtils.namedTimer(
778 error.retryAfter * 1000,
786 return { body: JSON.parse(response.body), headers: response.headers };
788 log.error("json parse error on response: " + response.body);
789 // eslint-disable-next-line no-throw-literal
794 async _request(path, method, credentials, jsonPayload) {
795 const response = await this._requestWithHeaders(
801 return response.body;
805 function isInvalidTokenError(error) {
806 if (error.code != 401) {
809 switch (error.errno) {
810 case ERRNO_INVALID_AUTH_TOKEN:
811 case ERRNO_INVALID_AUTH_TIMESTAMP:
812 case ERRNO_INVALID_AUTH_NONCE: