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.getStringPref(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.sys.mjs
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);
286 * Exchanges an OAuth authorization code with a refresh token, access tokens and an optional JWE representing scoped keys
287 * Takes in the sessionToken to tie the device record associated with the session, with the device record associated with the refreshToken
289 * @param string sessionTokenHex: The session token encoded in hex
290 * @param String code: OAuth authorization code
291 * @param String verifier: OAuth PKCE verifier
292 * @param String clientId: OAuth client ID
294 * @returns { Object } object containing `refresh_token`, `access_token` and `keys_jwe`
296 async oauthToken(sessionTokenHex, code, verifier, clientId) {
297 const credentials = await deriveHawkCredentials(
302 grant_type: "authorization_code",
305 code_verifier: verifier,
307 return this._request("/oauth/token", "POST", credentials, body);
310 * Destroy an OAuth access token or refresh token.
312 * @param String clientId
313 * @param String token The token to be revoked.
315 async oauthDestroy(clientId, token) {
320 return this._request("/oauth/destroy", "POST", null, body);
324 * Query for the information required to derive
325 * scoped encryption keys requested by the specified OAuth client.
327 * @param sessionTokenHex
328 * The session token encoded in hex
331 * Space separated list of scopes
334 async getScopedKeyData(sessionTokenHex, clientId, scope) {
336 throw new Error("Missing 'clientId' parameter");
339 throw new Error("Missing 'scope' parameter");
345 const credentials = await deriveHawkCredentials(
349 return this._request(
350 "/account/scoped-key-data",
358 * Destroy the current session with the Firefox Account API server and its
361 * @param sessionTokenHex
362 * The session token encoded in hex
365 async signOut(sessionTokenHex, options = {}) {
366 const credentials = await deriveHawkCredentials(
370 let path = "/session/destroy";
371 if (options.service) {
372 path += "?service=" + encodeURIComponent(options.service);
374 return this._request(path, "POST", credentials);
378 * Check the verification status of the user's FxA email address
380 * @param sessionTokenHex
381 * The current session token encoded in hex
384 async recoveryEmailStatus(sessionTokenHex, options = {}) {
385 const credentials = await deriveHawkCredentials(
389 let path = "/recovery_email/status";
390 if (options.reason) {
391 path += "?reason=" + encodeURIComponent(options.reason);
394 return this._request(path, "GET", credentials);
398 * Resend the verification email for the user
400 * @param sessionTokenHex
401 * The current token encoded in hex
404 async resendVerificationEmail(sessionTokenHex) {
405 const credentials = await deriveHawkCredentials(
409 return this._request("/recovery_email/resend_code", "POST", credentials);
413 * Retrieve encryption keys
415 * @param keyFetchTokenHex
416 * A one-time use key fetch token encoded in hex
418 * Returns a promise that resolves to an object:
420 * kA: an encryption key for recevorable data (bytes)
421 * wrapKB: an encryption key that requires knowledge of the
422 * user's password (bytes)
425 async accountKeys(keyFetchTokenHex) {
426 let creds = await deriveHawkCredentials(keyFetchTokenHex, "keyFetchToken");
427 let keyRequestKey = creds.extra.slice(0, 32);
428 let morecreds = await CryptoUtils.hkdfLegacy(
431 Credentials.keyWord("account/keys"),
434 let respHMACKey = morecreds.slice(0, 32);
435 let respXORKey = morecreds.slice(32, 96);
437 const resp = await this._request("/account/keys", "GET", creds);
439 throw new Error("failed to retrieve keys");
442 let bundle = CommonUtils.hexToBytes(resp.bundle);
443 let mac = bundle.slice(-32);
444 let key = CommonUtils.byteStringToArrayBuffer(respHMACKey);
445 // CryptoUtils.hmac takes ArrayBuffers as inputs for the key and data and
446 // returns an ArrayBuffer.
447 let bundleMAC = await CryptoUtils.hmac(
450 CommonUtils.byteStringToArrayBuffer(bundle.slice(0, -32))
452 if (mac !== CommonUtils.arrayBufferToByteString(bundleMAC)) {
453 throw new Error("error unbundling encryption keys");
456 let keyAWrapB = CryptoUtils.xor(respXORKey, bundle.slice(0, 64));
459 kA: keyAWrapB.slice(0, 32),
460 wrapKB: keyAWrapB.slice(32),
465 * Obtain an OAuth access token by authenticating using a session token.
467 * @param {String} sessionTokenHex
468 * The session token encoded in hex
469 * @param {String} clientId
470 * @param {String} scope
471 * List of space-separated scopes.
472 * @param {Number} ttl
473 * Token time to live.
474 * @return {Promise<Object>} Object containing an `access_token`.
476 async accessTokenWithSessionToken(sessionTokenHex, clientId, scope, ttl) {
477 const credentials = await deriveHawkCredentials(
483 grant_type: "fxa-credentials",
487 return this._request("/oauth/token", "POST", credentials, body);
491 * Determine if an account exists
494 * The email address to check
496 * The promise resolves to true if the account exists, or false
497 * if it doesn't. The promise is rejected on other errors.
499 accountExists(email) {
500 return this.signIn(email, "").then(
502 throw new Error("How did I sign in with an empty password?");
505 switch (expectedError.errno) {
506 case ERRNO_ACCOUNT_DOES_NOT_EXIST:
508 case ERRNO_INCORRECT_PASSWORD:
511 // not so expected, any more ...
519 * Given the uid of an existing account (not an arbitrary email), ask
520 * the server if it still exists via /account/status.
522 * Used for differentiating between password change and account deletion.
525 return this._request("/account/status?uid=" + uid, "GET").then(
527 return result.exists;
530 log.error("accountStatus failed", error);
531 return Promise.reject(error);
537 * Register a new device
539 * @method registerDevice
540 * @param sessionTokenHex
541 * Session token obtained from signIn
545 * Device type (mobile|desktop)
547 * Extra device options
548 * @param [options.availableCommands]
549 * Available commands for this device
550 * @param [options.pushCallback]
551 * `pushCallback` push endpoint callback
552 * @param [options.pushPublicKey]
553 * `pushPublicKey` push public key (URLSafe Base64 string)
554 * @param [options.pushAuthKey]
555 * `pushAuthKey` push auth secret (URLSafe Base64 string)
557 * Resolves to an object:
559 * id: Device identifier
560 * createdAt: Creation time (milliseconds since epoch)
561 * name: Name of device
562 * type: Type of device (mobile|desktop)
565 async registerDevice(sessionTokenHex, name, type, options = {}) {
566 let path = "/account/device";
568 let creds = await deriveHawkCredentials(sessionTokenHex, "sessionToken");
569 let body = { name, type };
571 if (options.pushCallback) {
572 body.pushCallback = options.pushCallback;
574 if (options.pushPublicKey && options.pushAuthKey) {
575 body.pushPublicKey = options.pushPublicKey;
576 body.pushAuthKey = options.pushAuthKey;
578 body.availableCommands = options.availableCommands;
580 return this._request(path, "POST", creds, body);
584 * Sends a message to other devices. Must conform with the push payload schema:
585 * https://github.com/mozilla/fxa-auth-server/blob/master/docs/pushpayloads.schema.json
587 * @method notifyDevice
588 * @param sessionTokenHex
589 * Session token obtained from signIn
591 * Devices to send the message to. If null, will be sent to all devices.
593 * Devices to exclude when sending to all devices (deviceIds must be null).
595 * Data to send with the message
597 * Resolves to an empty object:
607 const credentials = await deriveHawkCredentials(
611 if (deviceIds && excludedIds) {
613 "You cannot specify excluded devices if deviceIds is set."
617 to: deviceIds || "all",
622 body.excluded = excludedIds;
624 return this._request("/account/devices/notify", "POST", credentials, body);
628 * Retrieves pending commands for our device.
630 * @method getCommands
631 * @param sessionTokenHex - Session token obtained from signIn
632 * @param [index] - If specified, only messages received after the one who
633 * had that index will be retrieved.
634 * @param [limit] - Maximum number of messages to retrieve.
636 async getCommands(sessionTokenHex, { index, limit }) {
637 const credentials = await deriveHawkCredentials(
641 const params = new URLSearchParams();
642 if (index != undefined) {
643 params.set("index", index);
645 if (limit != undefined) {
646 params.set("limit", limit);
648 const path = `/account/device/commands?${params.toString()}`;
649 return this._request(path, "GET", credentials);
653 * Invokes a command on another device.
655 * @method invokeCommand
656 * @param sessionTokenHex - Session token obtained from signIn
657 * @param command - Name of the command to invoke
658 * @param target - Recipient device ID.
661 * Resolves to the request's response, (which should be an empty object)
663 async invokeCommand(sessionTokenHex, command, target, payload) {
664 const credentials = await deriveHawkCredentials(
673 return this._request(
674 "/account/devices/invoke_command",
682 * Update the session or name for an existing device
684 * @method updateDevice
685 * @param sessionTokenHex
686 * Session token obtained from signIn
692 * Extra device options
693 * @param [options.availableCommands]
694 * Available commands for this device
695 * @param [options.pushCallback]
696 * `pushCallback` push endpoint callback
697 * @param [options.pushPublicKey]
698 * `pushPublicKey` push public key (URLSafe Base64 string)
699 * @param [options.pushAuthKey]
700 * `pushAuthKey` push auth secret (URLSafe Base64 string)
702 * Resolves to an object:
704 * id: Device identifier
708 async updateDevice(sessionTokenHex, id, name, options = {}) {
709 let path = "/account/device";
711 let creds = await deriveHawkCredentials(sessionTokenHex, "sessionToken");
712 let body = { id, name };
713 if (options.pushCallback) {
714 body.pushCallback = options.pushCallback;
716 if (options.pushPublicKey && options.pushAuthKey) {
717 body.pushPublicKey = options.pushPublicKey;
718 body.pushAuthKey = options.pushAuthKey;
720 body.availableCommands = options.availableCommands;
722 return this._request(path, "POST", creds, body);
726 * Get a list of currently registered devices that have been accessed
727 * in the last `DEVICES_FILTER_DAYS` days
729 * @method getDeviceList
730 * @param sessionTokenHex
731 * Session token obtained from signIn
733 * Resolves to an array of objects:
737 * isCurrentDevice: Boolean indicating whether the item
738 * represents the current device
740 * type: Device type (mobile|desktop)
745 async getDeviceList(sessionTokenHex) {
746 let timestamp = Date.now() - 1000 * 60 * 60 * 24 * DEVICES_FILTER_DAYS;
747 let path = `/account/devices?filterIdleDevicesTimestamp=${timestamp}`;
748 let creds = await deriveHawkCredentials(sessionTokenHex, "sessionToken");
749 return this._request(path, "GET", creds, {});
753 this.backoffError = null;
757 * A general method for sending raw API calls to the FxA auth server.
758 * All request bodies and responses are JSON.
763 * The HTTP request method
769 * Returns a promise that resolves to the JSON response of the API call,
770 * or is rejected with an error. Error responses have the following properties:
772 * "code": 400, // matches the HTTP status code
773 * "errno": 107, // stable application-level error number
774 * "error": "Bad Request", // string description of the error type
775 * "message": "the value of salt is not allowed to be undefined",
776 * "info": "https://docs.dev.lcip.og/errors/1234" // link to more info on the error
779 async _requestWithHeaders(path, method, credentials, jsonPayload) {
780 // We were asked to back off.
781 if (this.backoffError) {
782 log.debug("Received new request during backoff, re-rejecting.");
783 throw this.backoffError;
787 response = await this.hawk.request(
794 log.error(`error ${method}ing ${path}`, error);
795 if (error.retryAfter) {
796 log.debug("Received backoff response; caching error as flag.");
797 this.backoffError = error;
798 // Schedule clearing of cached-error-as-flag.
799 CommonUtils.namedTimer(
801 error.retryAfter * 1000,
809 return { body: JSON.parse(response.body), headers: response.headers };
811 log.error("json parse error on response: " + response.body);
812 // eslint-disable-next-line no-throw-literal
817 async _request(path, method, credentials, jsonPayload) {
818 const response = await this._requestWithHeaders(
824 return response.body;
828 function isInvalidTokenError(error) {
829 if (error.code != 401) {
832 switch (error.errno) {
833 case ERRNO_INVALID_AUTH_TOKEN:
834 case ERRNO_INVALID_AUTH_TIMESTAMP:
835 case ERRNO_INVALID_AUTH_NONCE: