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 } = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
20 import { Credentials } from "resource://gre/modules/Credentials.sys.mjs";
22 const HOST_PREF = "identity.fxaccounts.auth.uri";
24 const SIGNIN = "/account/login";
25 const SIGNUP = "/account/create";
26 // Devices older than this many days will not appear in the devices list
27 const DEVICES_FILTER_DAYS = 21;
29 export var FxAccountsClient = function(
30 host = Services.prefs.getCharPref(HOST_PREF)
34 // The FxA auth server expects requests to certain endpoints to be authorized
36 this.hawk = new HawkClient(host);
37 this.hawk.observerPrefix = "FxA:hawk";
39 // Manage server backoff state. C.f.
40 // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#backoff-protocol
41 this.backoffError = null;
44 FxAccountsClient.prototype = {
46 * Return client clock offset, in milliseconds, as determined by hawk client.
47 * Provided because callers should not have to know about hawk
50 * The offset is the number of milliseconds that must be added to the client
51 * clock to make it equal to the server clock. For example, if the client is
52 * five minutes ahead of the server, the localtimeOffsetMsec will be -300000.
54 get localtimeOffsetMsec() {
55 return this.hawk.localtimeOffsetMsec;
59 * Return current time in milliseconds
61 * Not used by this module, but made available to the FxAccounts.jsm
62 * that uses this client.
65 return this.hawk.now();
69 * Common code from signIn and signUp.
72 * Request URL path. Can be /account/create or /account/login
74 * The email address for the account (utf8)
77 * @param [getKeys=false]
78 * If set to true the keyFetchToken will be retrieved
79 * @param [retryOK=true]
80 * If capitalization of the email is wrong and retryOK is set to true,
81 * we will retry with the suggested capitalization from the server
83 * Returns a promise that resolves to an object:
85 * authAt: authentication time for the session (seconds since epoch)
86 * email: the primary email for this account
87 * keyFetchToken: a key fetch token (hex)
88 * sessionToken: a session token (hex)
89 * uid: the user's unique ID (hex)
90 * unwrapBKey: used to unwrap kB, derived locally from the
91 * password (not revealed to the FxA server)
92 * verified (optional): flag indicating verification status of the
96 _createSession(path, email, password, getKeys = false, retryOK = true) {
97 return Credentials.setup(email, password).then(creds => {
99 authPW: CommonUtils.bytesAsHex(creds.authPW),
102 let keys = getKeys ? "?keys=true" : "";
104 return this._request(path + keys, "POST", null, data).then(
105 // Include the canonical capitalization of the email in the response so
106 // the caller can set its signed-in user state accordingly.
108 result.email = data.email;
109 result.unwrapBKey = CommonUtils.bytesAsHex(creds.unwrapBKey);
114 log.debug("Session creation failed", error);
115 // If the user entered an email with different capitalization from
116 // what's stored in the database (e.g., Greta.Garbo@gmail.COM as
117 // opposed to greta.garbo@gmail.com), the server will respond with a
118 // errno 120 (code 400) and the expected capitalization of the email.
119 // We retry with this email exactly once. If successful, we use the
120 // server's version of the email as the signed-in-user's email. This
121 // is necessary because the email also serves as salt; so we must be
122 // in agreement with the server on capitalization.
125 // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md
126 if (ERRNO_INCORRECT_EMAIL_CASE === error.errno && retryOK) {
128 log.error("Server returned errno 120 but did not provide email");
131 return this._createSession(
146 * Create a new Firefox Account and authenticate
149 * The email address for the account (utf8)
151 * The user's password
152 * @param [getKeys=false]
153 * If set to true the keyFetchToken will be retrieved
155 * Returns a promise that resolves to an object:
157 * uid: the user's unique ID (hex)
158 * sessionToken: a session token (hex)
159 * keyFetchToken: a key fetch token (hex),
160 * unwrapBKey: used to unwrap kB, derived locally from the
161 * password (not revealed to the FxA server)
164 signUp(email, password, getKeys = false) {
165 return this._createSession(
175 * Authenticate and create a new session with the Firefox Account API server
178 * The email address for the account (utf8)
180 * The user's password
181 * @param [getKeys=false]
182 * If set to true the keyFetchToken will be retrieved
184 * Returns a promise that resolves to an object:
186 * authAt: authentication time for the session (seconds since epoch)
187 * email: the primary email for this account
188 * keyFetchToken: a key fetch token (hex)
189 * sessionToken: a session token (hex)
190 * uid: the user's unique ID (hex)
191 * unwrapBKey: used to unwrap kB, derived locally from the
192 * password (not revealed to the FxA server)
193 * verified: flag indicating verification status of the email
196 signIn: function signIn(email, password, getKeys = false) {
197 return this._createSession(
207 * Check the status of a session given a session token
209 * @param sessionTokenHex
210 * The session token encoded in hex
212 * Resolves with a boolean indicating if the session is still valid
214 async sessionStatus(sessionTokenHex) {
215 const credentials = await deriveHawkCredentials(
219 return this._request("/session/status", "GET", credentials).then(
220 () => Promise.resolve(true),
222 if (isInvalidTokenError(error)) {
223 return Promise.resolve(false);
231 * List all the clients connected to the authenticated user's account,
232 * including devices, OAuth clients, and web sessions.
234 * @param sessionTokenHex
235 * The session token encoded in hex
238 async attachedClients(sessionTokenHex) {
239 const credentials = await deriveHawkCredentials(
243 return this._requestWithHeaders(
244 "/account/attached_clients",
251 * Retrieves an OAuth authorization code.
253 * @param String sessionTokenHex
254 * The session token encoded in hex
255 * @param {Object} options
256 * @param options.client_id
257 * @param options.state
258 * @param options.scope
259 * @param options.access_type
260 * @param options.code_challenge_method
261 * @param options.code_challenge
262 * @param [options.keys_jwe]
263 * @returns {Promise<Object>} Object containing `code` and `state`.
265 async oauthAuthorize(sessionTokenHex, options) {
266 const credentials = await deriveHawkCredentials(
271 client_id: options.client_id,
272 response_type: "code",
273 state: options.state,
274 scope: options.scope,
275 access_type: options.access_type,
276 code_challenge: options.code_challenge,
277 code_challenge_method: options.code_challenge_method,
279 if (options.keys_jwe) {
280 body.keys_jwe = options.keys_jwe;
282 return this._request("/oauth/authorization", "POST", credentials, body);
286 * Destroy an OAuth access token or refresh token.
288 * @param String clientId
289 * @param String token The token to be revoked.
291 async oauthDestroy(clientId, token) {
296 return this._request("/oauth/destroy", "POST", null, body);
300 * Query for the information required to derive
301 * scoped encryption keys requested by the specified OAuth client.
303 * @param sessionTokenHex
304 * The session token encoded in hex
307 * Space separated list of scopes
310 async getScopedKeyData(sessionTokenHex, clientId, scope) {
312 throw new Error("Missing 'clientId' parameter");
315 throw new Error("Missing 'scope' parameter");
321 const credentials = await deriveHawkCredentials(
325 return this._request(
326 "/account/scoped-key-data",
334 * Destroy the current session with the Firefox Account API server and its
337 * @param sessionTokenHex
338 * The session token encoded in hex
341 async signOut(sessionTokenHex, options = {}) {
342 const credentials = await deriveHawkCredentials(
346 let path = "/session/destroy";
347 if (options.service) {
348 path += "?service=" + encodeURIComponent(options.service);
350 return this._request(path, "POST", credentials);
354 * Check the verification status of the user's FxA email address
356 * @param sessionTokenHex
357 * The current session token encoded in hex
360 async recoveryEmailStatus(sessionTokenHex, options = {}) {
361 const credentials = await deriveHawkCredentials(
365 let path = "/recovery_email/status";
366 if (options.reason) {
367 path += "?reason=" + encodeURIComponent(options.reason);
370 return this._request(path, "GET", credentials);
374 * Resend the verification email for the user
376 * @param sessionTokenHex
377 * The current token encoded in hex
380 async resendVerificationEmail(sessionTokenHex) {
381 const credentials = await deriveHawkCredentials(
385 return this._request("/recovery_email/resend_code", "POST", credentials);
389 * Retrieve encryption keys
391 * @param keyFetchTokenHex
392 * A one-time use key fetch token encoded in hex
394 * Returns a promise that resolves to an object:
396 * kA: an encryption key for recevorable data (bytes)
397 * wrapKB: an encryption key that requires knowledge of the
398 * user's password (bytes)
401 async accountKeys(keyFetchTokenHex) {
402 let creds = await deriveHawkCredentials(keyFetchTokenHex, "keyFetchToken");
403 let keyRequestKey = creds.extra.slice(0, 32);
404 let morecreds = await CryptoUtils.hkdfLegacy(
407 Credentials.keyWord("account/keys"),
410 let respHMACKey = morecreds.slice(0, 32);
411 let respXORKey = morecreds.slice(32, 96);
413 const resp = await this._request("/account/keys", "GET", creds);
415 throw new Error("failed to retrieve keys");
418 let bundle = CommonUtils.hexToBytes(resp.bundle);
419 let mac = bundle.slice(-32);
420 let key = CommonUtils.byteStringToArrayBuffer(respHMACKey);
421 // CryptoUtils.hmac takes ArrayBuffers as inputs for the key and data and
422 // returns an ArrayBuffer.
423 let bundleMAC = await CryptoUtils.hmac(
426 CommonUtils.byteStringToArrayBuffer(bundle.slice(0, -32))
428 if (mac !== CommonUtils.arrayBufferToByteString(bundleMAC)) {
429 throw new Error("error unbundling encryption keys");
432 let keyAWrapB = CryptoUtils.xor(respXORKey, bundle.slice(0, 64));
435 kA: keyAWrapB.slice(0, 32),
436 wrapKB: keyAWrapB.slice(32),
441 * Obtain an OAuth access token by authenticating using a session token.
443 * @param {String} sessionTokenHex
444 * The session token encoded in hex
445 * @param {String} clientId
446 * @param {String} scope
447 * List of space-separated scopes.
448 * @param {Number} ttl
449 * Token time to live.
450 * @return {Promise<Object>} Object containing an `access_token`.
452 async accessTokenWithSessionToken(sessionTokenHex, clientId, scope, ttl) {
453 const credentials = await deriveHawkCredentials(
459 grant_type: "fxa-credentials",
463 return this._request("/oauth/token", "POST", credentials, body);
467 * Determine if an account exists
470 * The email address to check
472 * The promise resolves to true if the account exists, or false
473 * if it doesn't. The promise is rejected on other errors.
475 accountExists(email) {
476 return this.signIn(email, "").then(
478 throw new Error("How did I sign in with an empty password?");
481 switch (expectedError.errno) {
482 case ERRNO_ACCOUNT_DOES_NOT_EXIST:
484 case ERRNO_INCORRECT_PASSWORD:
487 // not so expected, any more ...
495 * Given the uid of an existing account (not an arbitrary email), ask
496 * the server if it still exists via /account/status.
498 * Used for differentiating between password change and account deletion.
501 return this._request("/account/status?uid=" + uid, "GET").then(
503 return result.exists;
506 log.error("accountStatus failed", error);
507 return Promise.reject(error);
513 * Register a new device
515 * @method registerDevice
516 * @param sessionTokenHex
517 * Session token obtained from signIn
521 * Device type (mobile|desktop)
523 * Extra device options
524 * @param [options.availableCommands]
525 * Available commands for this device
526 * @param [options.pushCallback]
527 * `pushCallback` push endpoint callback
528 * @param [options.pushPublicKey]
529 * `pushPublicKey` push public key (URLSafe Base64 string)
530 * @param [options.pushAuthKey]
531 * `pushAuthKey` push auth secret (URLSafe Base64 string)
533 * Resolves to an object:
535 * id: Device identifier
536 * createdAt: Creation time (milliseconds since epoch)
537 * name: Name of device
538 * type: Type of device (mobile|desktop)
541 async registerDevice(sessionTokenHex, name, type, options = {}) {
542 let path = "/account/device";
544 let creds = await deriveHawkCredentials(sessionTokenHex, "sessionToken");
545 let body = { name, type };
547 if (options.pushCallback) {
548 body.pushCallback = options.pushCallback;
550 if (options.pushPublicKey && options.pushAuthKey) {
551 body.pushPublicKey = options.pushPublicKey;
552 body.pushAuthKey = options.pushAuthKey;
554 body.availableCommands = options.availableCommands;
556 return this._request(path, "POST", creds, body);
560 * Sends a message to other devices. Must conform with the push payload schema:
561 * https://github.com/mozilla/fxa-auth-server/blob/master/docs/pushpayloads.schema.json
563 * @method notifyDevice
564 * @param sessionTokenHex
565 * Session token obtained from signIn
567 * Devices to send the message to. If null, will be sent to all devices.
569 * Devices to exclude when sending to all devices (deviceIds must be null).
571 * Data to send with the message
573 * Resolves to an empty object:
583 const credentials = await deriveHawkCredentials(
587 if (deviceIds && excludedIds) {
589 "You cannot specify excluded devices if deviceIds is set."
593 to: deviceIds || "all",
598 body.excluded = excludedIds;
600 return this._request("/account/devices/notify", "POST", credentials, body);
604 * Retrieves pending commands for our device.
606 * @method getCommands
607 * @param sessionTokenHex - Session token obtained from signIn
608 * @param [index] - If specified, only messages received after the one who
609 * had that index will be retrieved.
610 * @param [limit] - Maximum number of messages to retrieve.
612 async getCommands(sessionTokenHex, { index, limit }) {
613 const credentials = await deriveHawkCredentials(
617 const params = new URLSearchParams();
618 if (index != undefined) {
619 params.set("index", index);
621 if (limit != undefined) {
622 params.set("limit", limit);
624 const path = `/account/device/commands?${params.toString()}`;
625 return this._request(path, "GET", credentials);
629 * Invokes a command on another device.
631 * @method invokeCommand
632 * @param sessionTokenHex - Session token obtained from signIn
633 * @param command - Name of the command to invoke
634 * @param target - Recipient device ID.
637 * Resolves to the request's response, (which should be an empty object)
639 async invokeCommand(sessionTokenHex, command, target, payload) {
640 const credentials = await deriveHawkCredentials(
649 return this._request(
650 "/account/devices/invoke_command",
658 * Update the session or name for an existing device
660 * @method updateDevice
661 * @param sessionTokenHex
662 * Session token obtained from signIn
668 * Extra device options
669 * @param [options.availableCommands]
670 * Available commands for this device
671 * @param [options.pushCallback]
672 * `pushCallback` push endpoint callback
673 * @param [options.pushPublicKey]
674 * `pushPublicKey` push public key (URLSafe Base64 string)
675 * @param [options.pushAuthKey]
676 * `pushAuthKey` push auth secret (URLSafe Base64 string)
678 * Resolves to an object:
680 * id: Device identifier
684 async updateDevice(sessionTokenHex, id, name, options = {}) {
685 let path = "/account/device";
687 let creds = await deriveHawkCredentials(sessionTokenHex, "sessionToken");
688 let body = { id, name };
689 if (options.pushCallback) {
690 body.pushCallback = options.pushCallback;
692 if (options.pushPublicKey && options.pushAuthKey) {
693 body.pushPublicKey = options.pushPublicKey;
694 body.pushAuthKey = options.pushAuthKey;
696 body.availableCommands = options.availableCommands;
698 return this._request(path, "POST", creds, body);
702 * Get a list of currently registered devices that have been accessed
703 * in the last `DEVICES_FILTER_DAYS` days
705 * @method getDeviceList
706 * @param sessionTokenHex
707 * Session token obtained from signIn
709 * Resolves to an array of objects:
713 * isCurrentDevice: Boolean indicating whether the item
714 * represents the current device
716 * type: Device type (mobile|desktop)
721 async getDeviceList(sessionTokenHex) {
722 let timestamp = Date.now() - 1000 * 60 * 60 * 24 * DEVICES_FILTER_DAYS;
723 let path = `/account/devices?filterIdleDevicesTimestamp=${timestamp}`;
724 let creds = await deriveHawkCredentials(sessionTokenHex, "sessionToken");
725 return this._request(path, "GET", creds, {});
729 this.backoffError = null;
733 * A general method for sending raw API calls to the FxA auth server.
734 * All request bodies and responses are JSON.
739 * The HTTP request method
745 * Returns a promise that resolves to the JSON response of the API call,
746 * or is rejected with an error. Error responses have the following properties:
748 * "code": 400, // matches the HTTP status code
749 * "errno": 107, // stable application-level error number
750 * "error": "Bad Request", // string description of the error type
751 * "message": "the value of salt is not allowed to be undefined",
752 * "info": "https://docs.dev.lcip.og/errors/1234" // link to more info on the error
755 async _requestWithHeaders(path, method, credentials, jsonPayload) {
756 // We were asked to back off.
757 if (this.backoffError) {
758 log.debug("Received new request during backoff, re-rejecting.");
759 throw this.backoffError;
763 response = await this.hawk.request(
770 log.error(`error ${method}ing ${path}`, error);
771 if (error.retryAfter) {
772 log.debug("Received backoff response; caching error as flag.");
773 this.backoffError = error;
774 // Schedule clearing of cached-error-as-flag.
775 CommonUtils.namedTimer(
777 error.retryAfter * 1000,
785 return { body: JSON.parse(response.body), headers: response.headers };
787 log.error("json parse error on response: " + response.body);
788 // eslint-disable-next-line no-throw-literal
793 async _request(path, method, credentials, jsonPayload) {
794 const response = await this._requestWithHeaders(
800 return response.body;
804 function isInvalidTokenError(error) {
805 if (error.code != 401) {
808 switch (error.errno) {
809 case ERRNO_INVALID_AUTH_TOKEN:
810 case ERRNO_INVALID_AUTH_TIMESTAMP:
811 case ERRNO_INVALID_AUTH_NONCE: