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/. */ 4 5var EXPORTED_SYMBOLS = ["FxAccountsClient"]; 6 7const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); 8const { CommonUtils } = ChromeUtils.import( 9 "resource://services-common/utils.js" 10); 11const { HawkClient } = ChromeUtils.import( 12 "resource://services-common/hawkclient.js" 13); 14const { deriveHawkCredentials } = ChromeUtils.import( 15 "resource://services-common/hawkrequest.js" 16); 17const { CryptoUtils } = ChromeUtils.import( 18 "resource://services-crypto/utils.js" 19); 20const { 21 ERRNO_ACCOUNT_DOES_NOT_EXIST, 22 ERRNO_INCORRECT_EMAIL_CASE, 23 ERRNO_INCORRECT_PASSWORD, 24 ERRNO_INVALID_AUTH_NONCE, 25 ERRNO_INVALID_AUTH_TIMESTAMP, 26 ERRNO_INVALID_AUTH_TOKEN, 27 log, 28} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js"); 29const { Credentials } = ChromeUtils.import( 30 "resource://gre/modules/Credentials.jsm" 31); 32 33const HOST_PREF = "identity.fxaccounts.auth.uri"; 34 35const SIGNIN = "/account/login"; 36const SIGNUP = "/account/create"; 37 38var FxAccountsClient = function(host = Services.prefs.getCharPref(HOST_PREF)) { 39 this.host = host; 40 41 // The FxA auth server expects requests to certain endpoints to be authorized 42 // using Hawk. 43 this.hawk = new HawkClient(host); 44 this.hawk.observerPrefix = "FxA:hawk"; 45 46 // Manage server backoff state. C.f. 47 // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#backoff-protocol 48 this.backoffError = null; 49}; 50 51FxAccountsClient.prototype = { 52 /** 53 * Return client clock offset, in milliseconds, as determined by hawk client. 54 * Provided because callers should not have to know about hawk 55 * implementation. 56 * 57 * The offset is the number of milliseconds that must be added to the client 58 * clock to make it equal to the server clock. For example, if the client is 59 * five minutes ahead of the server, the localtimeOffsetMsec will be -300000. 60 */ 61 get localtimeOffsetMsec() { 62 return this.hawk.localtimeOffsetMsec; 63 }, 64 65 /* 66 * Return current time in milliseconds 67 * 68 * Not used by this module, but made available to the FxAccounts.jsm 69 * that uses this client. 70 */ 71 now() { 72 return this.hawk.now(); 73 }, 74 75 /** 76 * Common code from signIn and signUp. 77 * 78 * @param path 79 * Request URL path. Can be /account/create or /account/login 80 * @param email 81 * The email address for the account (utf8) 82 * @param password 83 * The user's password 84 * @param [getKeys=false] 85 * If set to true the keyFetchToken will be retrieved 86 * @param [retryOK=true] 87 * If capitalization of the email is wrong and retryOK is set to true, 88 * we will retry with the suggested capitalization from the server 89 * @return Promise 90 * Returns a promise that resolves to an object: 91 * { 92 * authAt: authentication time for the session (seconds since epoch) 93 * email: the primary email for this account 94 * keyFetchToken: a key fetch token (hex) 95 * sessionToken: a session token (hex) 96 * uid: the user's unique ID (hex) 97 * unwrapBKey: used to unwrap kB, derived locally from the 98 * password (not revealed to the FxA server) 99 * verified (optional): flag indicating verification status of the 100 * email 101 * } 102 */ 103 _createSession(path, email, password, getKeys = false, retryOK = true) { 104 return Credentials.setup(email, password).then(creds => { 105 let data = { 106 authPW: CommonUtils.bytesAsHex(creds.authPW), 107 email, 108 }; 109 let keys = getKeys ? "?keys=true" : ""; 110 111 return this._request(path + keys, "POST", null, data).then( 112 // Include the canonical capitalization of the email in the response so 113 // the caller can set its signed-in user state accordingly. 114 result => { 115 result.email = data.email; 116 result.unwrapBKey = CommonUtils.bytesAsHex(creds.unwrapBKey); 117 118 return result; 119 }, 120 error => { 121 log.debug("Session creation failed", error); 122 // If the user entered an email with different capitalization from 123 // what's stored in the database (e.g., Greta.Garbo@gmail.COM as 124 // opposed to greta.garbo@gmail.com), the server will respond with a 125 // errno 120 (code 400) and the expected capitalization of the email. 126 // We retry with this email exactly once. If successful, we use the 127 // server's version of the email as the signed-in-user's email. This 128 // is necessary because the email also serves as salt; so we must be 129 // in agreement with the server on capitalization. 130 // 131 // API reference: 132 // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md 133 if (ERRNO_INCORRECT_EMAIL_CASE === error.errno && retryOK) { 134 if (!error.email) { 135 log.error("Server returned errno 120 but did not provide email"); 136 throw error; 137 } 138 return this._createSession( 139 path, 140 error.email, 141 password, 142 getKeys, 143 false 144 ); 145 } 146 throw error; 147 } 148 ); 149 }); 150 }, 151 152 /** 153 * Create a new Firefox Account and authenticate 154 * 155 * @param email 156 * The email address for the account (utf8) 157 * @param password 158 * The user's password 159 * @param [getKeys=false] 160 * If set to true the keyFetchToken will be retrieved 161 * @return Promise 162 * Returns a promise that resolves to an object: 163 * { 164 * uid: the user's unique ID (hex) 165 * sessionToken: a session token (hex) 166 * keyFetchToken: a key fetch token (hex), 167 * unwrapBKey: used to unwrap kB, derived locally from the 168 * password (not revealed to the FxA server) 169 * } 170 */ 171 signUp(email, password, getKeys = false) { 172 return this._createSession( 173 SIGNUP, 174 email, 175 password, 176 getKeys, 177 false /* no retry */ 178 ); 179 }, 180 181 /** 182 * Authenticate and create a new session with the Firefox Account API server 183 * 184 * @param email 185 * The email address for the account (utf8) 186 * @param password 187 * The user's password 188 * @param [getKeys=false] 189 * If set to true the keyFetchToken will be retrieved 190 * @return Promise 191 * Returns a promise that resolves to an object: 192 * { 193 * authAt: authentication time for the session (seconds since epoch) 194 * email: the primary email for this account 195 * keyFetchToken: a key fetch token (hex) 196 * sessionToken: a session token (hex) 197 * uid: the user's unique ID (hex) 198 * unwrapBKey: used to unwrap kB, derived locally from the 199 * password (not revealed to the FxA server) 200 * verified: flag indicating verification status of the email 201 * } 202 */ 203 signIn: function signIn(email, password, getKeys = false) { 204 return this._createSession( 205 SIGNIN, 206 email, 207 password, 208 getKeys, 209 true /* retry */ 210 ); 211 }, 212 213 /** 214 * Check the status of a session given a session token 215 * 216 * @param sessionTokenHex 217 * The session token encoded in hex 218 * @return Promise 219 * Resolves with a boolean indicating if the session is still valid 220 */ 221 async sessionStatus(sessionTokenHex) { 222 const credentials = await deriveHawkCredentials( 223 sessionTokenHex, 224 "sessionToken" 225 ); 226 return this._request("/session/status", "GET", credentials).then( 227 () => Promise.resolve(true), 228 error => { 229 if (isInvalidTokenError(error)) { 230 return Promise.resolve(false); 231 } 232 throw error; 233 } 234 ); 235 }, 236 237 /** 238 * List all the clients connected to the authenticated user's account, 239 * including devices, OAuth clients, and web sessions. 240 * 241 * @param sessionTokenHex 242 * The session token encoded in hex 243 * @return Promise 244 */ 245 async attachedClients(sessionTokenHex) { 246 const credentials = await deriveHawkCredentials( 247 sessionTokenHex, 248 "sessionToken" 249 ); 250 return this._request("/account/attached_clients", "GET", credentials); 251 }, 252 253 /** 254 * Retrieves an OAuth authorization code. 255 * 256 * @param String sessionTokenHex 257 * The session token encoded in hex 258 * @param {Object} options 259 * @param options.client_id 260 * @param options.state 261 * @param options.scope 262 * @param options.access_type 263 * @param options.code_challenge_method 264 * @param options.code_challenge 265 * @param [options.keys_jwe] 266 * @returns {Promise<Object>} Object containing `code` and `state`. 267 */ 268 async oauthAuthorize(sessionTokenHex, options) { 269 const credentials = await deriveHawkCredentials( 270 sessionTokenHex, 271 "sessionToken" 272 ); 273 const body = { 274 client_id: options.client_id, 275 response_type: "code", 276 state: options.state, 277 scope: options.scope, 278 access_type: options.access_type, 279 code_challenge: options.code_challenge, 280 code_challenge_method: options.code_challenge_method, 281 }; 282 if (options.keys_jwe) { 283 body.keys_jwe = options.keys_jwe; 284 } 285 return this._request("/oauth/authorization", "POST", credentials, body); 286 }, 287 288 /** 289 * Destroy an OAuth access token or refresh token. 290 * 291 * @param String clientId 292 * @param String token The token to be revoked. 293 */ 294 async oauthDestroy(clientId, token) { 295 const body = { 296 client_id: clientId, 297 token, 298 }; 299 return this._request("/oauth/destroy", "POST", null, body); 300 }, 301 302 /** 303 * Query for the information required to derive 304 * scoped encryption keys requested by the specified OAuth client. 305 * 306 * @param sessionTokenHex 307 * The session token encoded in hex 308 * @param clientId 309 * @param scope 310 * Space separated list of scopes 311 * @return Promise 312 */ 313 async getScopedKeyData(sessionTokenHex, clientId, scope) { 314 if (!clientId) { 315 throw new Error("Missing 'clientId' parameter"); 316 } 317 if (!scope) { 318 throw new Error("Missing 'scope' parameter"); 319 } 320 const params = { 321 client_id: clientId, 322 scope, 323 }; 324 const credentials = await deriveHawkCredentials( 325 sessionTokenHex, 326 "sessionToken" 327 ); 328 return this._request( 329 "/account/scoped-key-data", 330 "POST", 331 credentials, 332 params 333 ); 334 }, 335 336 /** 337 * Destroy the current session with the Firefox Account API server and its 338 * associated device. 339 * 340 * @param sessionTokenHex 341 * The session token encoded in hex 342 * @return Promise 343 */ 344 async signOut(sessionTokenHex, options = {}) { 345 const credentials = await deriveHawkCredentials( 346 sessionTokenHex, 347 "sessionToken" 348 ); 349 let path = "/session/destroy"; 350 if (options.service) { 351 path += "?service=" + encodeURIComponent(options.service); 352 } 353 return this._request(path, "POST", credentials); 354 }, 355 356 /** 357 * Check the verification status of the user's FxA email address 358 * 359 * @param sessionTokenHex 360 * The current session token encoded in hex 361 * @return Promise 362 */ 363 async recoveryEmailStatus(sessionTokenHex, options = {}) { 364 const credentials = await deriveHawkCredentials( 365 sessionTokenHex, 366 "sessionToken" 367 ); 368 let path = "/recovery_email/status"; 369 if (options.reason) { 370 path += "?reason=" + encodeURIComponent(options.reason); 371 } 372 373 return this._request(path, "GET", credentials); 374 }, 375 376 /** 377 * Resend the verification email for the user 378 * 379 * @param sessionTokenHex 380 * The current token encoded in hex 381 * @return Promise 382 */ 383 async resendVerificationEmail(sessionTokenHex) { 384 const credentials = await deriveHawkCredentials( 385 sessionTokenHex, 386 "sessionToken" 387 ); 388 return this._request("/recovery_email/resend_code", "POST", credentials); 389 }, 390 391 /** 392 * Retrieve encryption keys 393 * 394 * @param keyFetchTokenHex 395 * A one-time use key fetch token encoded in hex 396 * @return Promise 397 * Returns a promise that resolves to an object: 398 * { 399 * kA: an encryption key for recevorable data (bytes) 400 * wrapKB: an encryption key that requires knowledge of the 401 * user's password (bytes) 402 * } 403 */ 404 async accountKeys(keyFetchTokenHex) { 405 let creds = await deriveHawkCredentials(keyFetchTokenHex, "keyFetchToken"); 406 let keyRequestKey = creds.extra.slice(0, 32); 407 let morecreds = await CryptoUtils.hkdfLegacy( 408 keyRequestKey, 409 undefined, 410 Credentials.keyWord("account/keys"), 411 3 * 32 412 ); 413 let respHMACKey = morecreds.slice(0, 32); 414 let respXORKey = morecreds.slice(32, 96); 415 416 const resp = await this._request("/account/keys", "GET", creds); 417 if (!resp.bundle) { 418 throw new Error("failed to retrieve keys"); 419 } 420 421 let bundle = CommonUtils.hexToBytes(resp.bundle); 422 let mac = bundle.slice(-32); 423 424 let hasher = CryptoUtils.makeHMACHasher( 425 Ci.nsICryptoHMAC.SHA256, 426 CryptoUtils.makeHMACKey(respHMACKey) 427 ); 428 429 let bundleMAC = CryptoUtils.digestBytes(bundle.slice(0, -32), hasher); 430 if (mac !== bundleMAC) { 431 throw new Error("error unbundling encryption keys"); 432 } 433 434 let keyAWrapB = CryptoUtils.xor(respXORKey, bundle.slice(0, 64)); 435 436 return { 437 kA: keyAWrapB.slice(0, 32), 438 wrapKB: keyAWrapB.slice(32), 439 }; 440 }, 441 442 /** 443 * Obtain an OAuth access token by authenticating using a session token. 444 * 445 * @param {String} sessionTokenHex 446 * The session token encoded in hex 447 * @param {String} clientId 448 * @param {String} scope 449 * List of space-separated scopes. 450 * @param {Number} ttl 451 * Token time to live. 452 * @return {Promise<Object>} Object containing an `access_token`. 453 */ 454 async accessTokenWithSessionToken(sessionTokenHex, clientId, scope, ttl) { 455 const credentials = await deriveHawkCredentials( 456 sessionTokenHex, 457 "sessionToken" 458 ); 459 const body = { 460 client_id: clientId, 461 grant_type: "fxa-credentials", 462 scope, 463 ttl, 464 }; 465 return this._request("/oauth/token", "POST", credentials, body); 466 }, 467 468 /** 469 * Determine if an account exists 470 * 471 * @param email 472 * The email address to check 473 * @return Promise 474 * The promise resolves to true if the account exists, or false 475 * if it doesn't. The promise is rejected on other errors. 476 */ 477 accountExists(email) { 478 return this.signIn(email, "").then( 479 cantHappen => { 480 throw new Error("How did I sign in with an empty password?"); 481 }, 482 expectedError => { 483 switch (expectedError.errno) { 484 case ERRNO_ACCOUNT_DOES_NOT_EXIST: 485 return false; 486 case ERRNO_INCORRECT_PASSWORD: 487 return true; 488 default: 489 // not so expected, any more ... 490 throw expectedError; 491 } 492 } 493 ); 494 }, 495 496 /** 497 * Given the uid of an existing account (not an arbitrary email), ask 498 * the server if it still exists via /account/status. 499 * 500 * Used for differentiating between password change and account deletion. 501 */ 502 accountStatus(uid) { 503 return this._request("/account/status?uid=" + uid, "GET").then( 504 result => { 505 return result.exists; 506 }, 507 error => { 508 log.error("accountStatus failed", error); 509 return Promise.reject(error); 510 } 511 ); 512 }, 513 514 /** 515 * Register a new device 516 * 517 * @method registerDevice 518 * @param sessionTokenHex 519 * Session token obtained from signIn 520 * @param name 521 * Device name 522 * @param type 523 * Device type (mobile|desktop) 524 * @param [options] 525 * Extra device options 526 * @param [options.availableCommands] 527 * Available commands for this device 528 * @param [options.pushCallback] 529 * `pushCallback` push endpoint callback 530 * @param [options.pushPublicKey] 531 * `pushPublicKey` push public key (URLSafe Base64 string) 532 * @param [options.pushAuthKey] 533 * `pushAuthKey` push auth secret (URLSafe Base64 string) 534 * @return Promise 535 * Resolves to an object: 536 * { 537 * id: Device identifier 538 * createdAt: Creation time (milliseconds since epoch) 539 * name: Name of device 540 * type: Type of device (mobile|desktop) 541 * } 542 */ 543 async registerDevice(sessionTokenHex, name, type, options = {}) { 544 let path = "/account/device"; 545 546 let creds = await deriveHawkCredentials(sessionTokenHex, "sessionToken"); 547 let body = { name, type }; 548 549 if (options.pushCallback) { 550 body.pushCallback = options.pushCallback; 551 } 552 if (options.pushPublicKey && options.pushAuthKey) { 553 body.pushPublicKey = options.pushPublicKey; 554 body.pushAuthKey = options.pushAuthKey; 555 } 556 body.availableCommands = options.availableCommands; 557 558 return this._request(path, "POST", creds, body); 559 }, 560 561 /** 562 * Sends a message to other devices. Must conform with the push payload schema: 563 * https://github.com/mozilla/fxa-auth-server/blob/master/docs/pushpayloads.schema.json 564 * 565 * @method notifyDevice 566 * @param sessionTokenHex 567 * Session token obtained from signIn 568 * @param deviceIds 569 * Devices to send the message to. If null, will be sent to all devices. 570 * @param excludedIds 571 * Devices to exclude when sending to all devices (deviceIds must be null). 572 * @param payload 573 * Data to send with the message 574 * @return Promise 575 * Resolves to an empty object: 576 * {} 577 */ 578 async notifyDevices( 579 sessionTokenHex, 580 deviceIds, 581 excludedIds, 582 payload, 583 TTL = 0 584 ) { 585 const credentials = await deriveHawkCredentials( 586 sessionTokenHex, 587 "sessionToken" 588 ); 589 if (deviceIds && excludedIds) { 590 throw new Error( 591 "You cannot specify excluded devices if deviceIds is set." 592 ); 593 } 594 const body = { 595 to: deviceIds || "all", 596 payload, 597 TTL, 598 }; 599 if (excludedIds) { 600 body.excluded = excludedIds; 601 } 602 return this._request("/account/devices/notify", "POST", credentials, body); 603 }, 604 605 /** 606 * Retrieves pending commands for our device. 607 * 608 * @method getCommands 609 * @param sessionTokenHex - Session token obtained from signIn 610 * @param [index] - If specified, only messages received after the one who 611 * had that index will be retrieved. 612 * @param [limit] - Maximum number of messages to retrieve. 613 */ 614 async getCommands(sessionTokenHex, { index, limit }) { 615 const credentials = await deriveHawkCredentials( 616 sessionTokenHex, 617 "sessionToken" 618 ); 619 const params = new URLSearchParams(); 620 if (index != undefined) { 621 params.set("index", index); 622 } 623 if (limit != undefined) { 624 params.set("limit", limit); 625 } 626 const path = `/account/device/commands?${params.toString()}`; 627 return this._request(path, "GET", credentials); 628 }, 629 630 /** 631 * Invokes a command on another device. 632 * 633 * @method invokeCommand 634 * @param sessionTokenHex - Session token obtained from signIn 635 * @param command - Name of the command to invoke 636 * @param target - Recipient device ID. 637 * @param payload 638 * @return Promise 639 * Resolves to the request's response, (which should be an empty object) 640 */ 641 async invokeCommand(sessionTokenHex, command, target, payload) { 642 const credentials = await deriveHawkCredentials( 643 sessionTokenHex, 644 "sessionToken" 645 ); 646 const body = { 647 command, 648 target, 649 payload, 650 }; 651 return this._request( 652 "/account/devices/invoke_command", 653 "POST", 654 credentials, 655 body 656 ); 657 }, 658 659 /** 660 * Update the session or name for an existing device 661 * 662 * @method updateDevice 663 * @param sessionTokenHex 664 * Session token obtained from signIn 665 * @param id 666 * Device identifier 667 * @param name 668 * Device name 669 * @param [options] 670 * Extra device options 671 * @param [options.availableCommands] 672 * Available commands for this device 673 * @param [options.pushCallback] 674 * `pushCallback` push endpoint callback 675 * @param [options.pushPublicKey] 676 * `pushPublicKey` push public key (URLSafe Base64 string) 677 * @param [options.pushAuthKey] 678 * `pushAuthKey` push auth secret (URLSafe Base64 string) 679 * @return Promise 680 * Resolves to an object: 681 * { 682 * id: Device identifier 683 * name: Device name 684 * } 685 */ 686 async updateDevice(sessionTokenHex, id, name, options = {}) { 687 let path = "/account/device"; 688 689 let creds = await deriveHawkCredentials(sessionTokenHex, "sessionToken"); 690 let body = { id, name }; 691 if (options.pushCallback) { 692 body.pushCallback = options.pushCallback; 693 } 694 if (options.pushPublicKey && options.pushAuthKey) { 695 body.pushPublicKey = options.pushPublicKey; 696 body.pushAuthKey = options.pushAuthKey; 697 } 698 body.availableCommands = options.availableCommands; 699 700 return this._request(path, "POST", creds, body); 701 }, 702 703 /** 704 * Get a list of currently registered devices 705 * 706 * @method getDeviceList 707 * @param sessionTokenHex 708 * Session token obtained from signIn 709 * @return Promise 710 * Resolves to an array of objects: 711 * [ 712 * { 713 * id: Device id 714 * isCurrentDevice: Boolean indicating whether the item 715 * represents the current device 716 * name: Device name 717 * type: Device type (mobile|desktop) 718 * }, 719 * ... 720 * ] 721 */ 722 async getDeviceList(sessionTokenHex) { 723 let path = "/account/devices"; 724 let creds = await deriveHawkCredentials(sessionTokenHex, "sessionToken"); 725 726 return this._request(path, "GET", creds, {}); 727 }, 728 729 _clearBackoff() { 730 this.backoffError = null; 731 }, 732 733 /** 734 * A general method for sending raw API calls to the FxA auth server. 735 * All request bodies and responses are JSON. 736 * 737 * @param path 738 * API endpoint path 739 * @param method 740 * The HTTP request method 741 * @param credentials 742 * Hawk credentials 743 * @param jsonPayload 744 * A JSON payload 745 * @return Promise 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: 748 * { 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 754 * } 755 */ 756 async _request(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; 761 } 762 let response; 763 try { 764 response = await this.hawk.request( 765 path, 766 method, 767 credentials, 768 jsonPayload 769 ); 770 } catch (error) { 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( 777 this._clearBackoff, 778 error.retryAfter * 1000, 779 this, 780 "fxaBackoffTimer" 781 ); 782 } 783 throw error; 784 } 785 try { 786 return JSON.parse(response.body); 787 } catch (error) { 788 log.error("json parse error on response: " + response.body); 789 // eslint-disable-next-line no-throw-literal 790 throw { error }; 791 } 792 }, 793}; 794 795function isInvalidTokenError(error) { 796 if (error.code != 401) { 797 return false; 798 } 799 switch (error.errno) { 800 case ERRNO_INVALID_AUTH_TOKEN: 801 case ERRNO_INVALID_AUTH_TIMESTAMP: 802 case ERRNO_INVALID_AUTH_NONCE: 803 return true; 804 } 805 return false; 806} 807