diff options
Diffstat (limited to 'services/fxaccounts/tests/xpcshell/test_accounts.js')
-rw-r--r-- | services/fxaccounts/tests/xpcshell/test_accounts.js | 2012 |
1 files changed, 2012 insertions, 0 deletions
diff --git a/services/fxaccounts/tests/xpcshell/test_accounts.js b/services/fxaccounts/tests/xpcshell/test_accounts.js new file mode 100644 index 0000000000..be9e77e52b --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_accounts.js @@ -0,0 +1,2012 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { FxAccounts } = ChromeUtils.import( + "resource://gre/modules/FxAccounts.jsm" +); +const { FxAccountsClient } = ChromeUtils.import( + "resource://gre/modules/FxAccountsClient.jsm" +); +const { + ASSERTION_LIFETIME, + CERT_LIFETIME, + ERRNO_INVALID_AUTH_TOKEN, + ERROR_NETWORK, + ERROR_NO_ACCOUNT, + FX_OAUTH_CLIENT_ID, + KEY_LIFETIME, + ONLOGIN_NOTIFICATION, + ONLOGOUT_NOTIFICATION, + ONVERIFIED_NOTIFICATION, +} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js"); +const { PromiseUtils } = ChromeUtils.import( + "resource://gre/modules/PromiseUtils.jsm" +); + +// We grab some additional stuff via backstage passes. +var { AccountState } = ChromeUtils.import( + "resource://gre/modules/FxAccounts.jsm", + null +); + +const ONE_HOUR_MS = 1000 * 60 * 60; +const ONE_DAY_MS = ONE_HOUR_MS * 24; +const MOCK_TOKEN_RESPONSE = { + access_token: + "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69", + token_type: "bearer", + scope: "https://identity.mozilla.com/apps/oldsync", + expires_in: 21600, + auth_at: 1589579900, +}; + +initTestLogging("Trace"); + +var log = Log.repository.getLogger("Services.FxAccounts.test"); +log.level = Log.Level.Debug; + +// See verbose logging from FxAccounts.jsm and jwcrypto.jsm. +Services.prefs.setCharPref("identity.fxaccounts.loglevel", "Trace"); +Log.repository.getLogger("FirefoxAccounts").level = Log.Level.Trace; +Services.prefs.setCharPref("services.crypto.jwcrypto.log.level", "Debug"); + +/* + * The FxAccountsClient communicates with the remote Firefox + * Accounts auth server. Mock the server calls, with a little + * lag time to simulate some latency. + * + * We add the _verified attribute to mock the change in verification + * state on the FXA server. + */ + +function MockStorageManager() {} + +MockStorageManager.prototype = { + promiseInitialized: Promise.resolve(), + + initialize(accountData) { + this.accountData = accountData; + }, + + finalize() { + return Promise.resolve(); + }, + + getAccountData(fields = null) { + let result; + if (!this.accountData) { + result = null; + } else if (fields == null) { + // can't use cloneInto as the keys get upset... + result = {}; + for (let field of Object.keys(this.accountData)) { + result[field] = this.accountData[field]; + } + } else { + if (!Array.isArray(fields)) { + fields = [fields]; + } + result = {}; + for (let field of fields) { + result[field] = this.accountData[field]; + } + } + return Promise.resolve(result); + }, + + updateAccountData(updatedFields) { + if (!this.accountData) { + return Promise.resolve(); + } + for (let [name, value] of Object.entries(updatedFields)) { + if (value == null) { + delete this.accountData[name]; + } else { + this.accountData[name] = value; + } + } + return Promise.resolve(); + }, + + deleteAccountData() { + this.accountData = null; + return Promise.resolve(); + }, +}; + +function MockFxAccountsClient() { + this._email = "nobody@example.com"; + this._verified = false; + this._deletedOnServer = false; // for our accountStatus mock + + // mock calls up to the auth server to determine whether the + // user account has been verified + this.recoveryEmailStatus = async function(sessionToken) { + // simulate a call to /recovery_email/status + return { + email: this._email, + verified: this._verified, + }; + }; + + this.accountStatus = async function(uid) { + return !!uid && !this._deletedOnServer; + }; + + this.sessionStatus = async function() { + // If the sessionStatus check says an account is OK, we typically will not + // end up calling accountStatus - so this must return false if accountStatus + // would. + return !this._deletedOnServer; + }; + + this.accountKeys = function(keyFetchToken) { + return new Promise(resolve => { + do_timeout(50, () => { + resolve({ + kA: expandBytes("11"), + wrapKB: expandBytes("22"), + }); + }); + }); + }; + + this.getScopedKeyData = function(sessionToken, client_id, scopes) { + Assert.ok(sessionToken); + Assert.equal(client_id, FX_OAUTH_CLIENT_ID); + Assert.equal(scopes, SCOPE_OLD_SYNC + " " + SCOPE_ECOSYSTEM_TELEMETRY); + return new Promise(resolve => { + do_timeout(50, () => { + resolve({ + "https://identity.mozilla.com/apps/oldsync": { + identifier: "https://identity.mozilla.com/apps/oldsync", + keyRotationSecret: + "0000000000000000000000000000000000000000000000000000000000000000", + keyRotationTimestamp: 1234567890123, + }, + "https://identity.mozilla.com/ids/ecosystem_telemetry": { + identifier: "https://identity.mozilla.com/ids/ecosystem_telemetry", + keyRotationSecret: + "0000000000000000000000000000000000000000000000000000000000000000", + keyRotationTimestamp: 1234567890123, + }, + }); + }); + }); + }; + + this.resendVerificationEmail = function(sessionToken) { + // Return the session token to show that we received it in the first place + return Promise.resolve(sessionToken); + }; + + this.signCertificate = function() { + throw new Error("no"); + }; + + this.signOut = () => Promise.resolve(); + + FxAccountsClient.apply(this); +} +MockFxAccountsClient.prototype = { + __proto__: FxAccountsClient.prototype, +}; + +/* + * We need to mock the FxAccounts module's interfaces to external + * services, such as storage and the FxAccounts client. We also + * mock the now() method, so that we can simulate the passing of + * time and verify that signatures expire correctly. + */ +function MockFxAccounts(credentials = null) { + let result = new FxAccounts({ + VERIFICATION_POLL_TIMEOUT_INITIAL: 100, // 100ms + + _getCertificateSigned_calls: [], + _d_signCertificate: PromiseUtils.defer(), + _now_is: new Date(), + now() { + return this._now_is; + }, + newAccountState(newCredentials) { + // we use a real accountState but mocked storage. + let storage = new MockStorageManager(); + storage.initialize(newCredentials); + return new AccountState(storage); + }, + getCertificateSigned(sessionToken, serializedPublicKey) { + _("mock getCertificateSigned\n"); + this._getCertificateSigned_calls.push([ + sessionToken, + serializedPublicKey, + ]); + return this._d_signCertificate.promise; + }, + fxAccountsClient: new MockFxAccountsClient(), + observerPreloads: [], + device: { + _registerOrUpdateDevice() {}, + }, + profile: { + getProfile() { + return null; + }, + }, + }); + // and for convenience so we don't have to touch as many lines in this test + // when we refactored FxAccounts.jsm :) + result.setSignedInUser = function(creds) { + return result._internal.setSignedInUser(creds); + }; + return result; +} + +/* + * Some tests want a "real" fxa instance - however, we still mock the storage + * to keep the tests fast on b2g. + */ +async function MakeFxAccounts({ internal = {}, credentials } = {}) { + if (!internal.newAccountState) { + // we use a real accountState but mocked storage. + internal.newAccountState = function(newCredentials) { + let storage = new MockStorageManager(); + storage.initialize(newCredentials); + return new AccountState(storage); + }; + } + if (!internal._signOutServer) { + internal._signOutServer = () => Promise.resolve(); + } + if (internal.device) { + if (!internal.device._registerOrUpdateDevice) { + internal.device._registerOrUpdateDevice = () => Promise.resolve(); + } + } else { + internal.device = { + _registerOrUpdateDevice() {}, + }; + } + if (!internal.observerPreloads) { + internal.observerPreloads = []; + } + let result = new FxAccounts(internal); + + if (credentials) { + await result._internal.setSignedInUser(credentials); + } + return result; +} + +add_task(async function test_get_signed_in_user_initially_unset() { + _("Check getSignedInUser initially and after signout reports no user"); + let account = await MakeFxAccounts(); + let credentials = { + email: "foo@example.com", + uid: "1234567890abcdef1234567890abcdef", + assertion: "foobar", + sessionToken: "dead", + verified: true, + ...MOCK_ACCOUNT_KEYS, + }; + let result = await account.getSignedInUser(); + Assert.equal(result, null); + + await account._internal.setSignedInUser(credentials); + + // getSignedInUser only returns a subset. + result = await account.getSignedInUser(); + Assert.deepEqual(result.email, credentials.email); + Assert.deepEqual(result.assertion, undefined); + Assert.deepEqual(result.scopedKeys, undefined); + Assert.deepEqual(result.kSync, undefined); + Assert.deepEqual(result.kXCS, undefined); + Assert.deepEqual(result.kExtSync, undefined); + Assert.deepEqual(result.kExtKbHash, undefined); + // for the sake of testing, use the low-level function to check it's all there + result = await account._internal.currentAccountState.getUserAccountData(); + Assert.deepEqual(result.email, credentials.email); + Assert.deepEqual(result.assertion, credentials.assertion); + Assert.deepEqual(result.scopedKeys, credentials.scopedKeys); + Assert.ok(result.kSync); + Assert.ok(result.kXCS); + Assert.ok(result.kExtSync); + Assert.ok(result.kExtKbHash); + + // sign out + let localOnly = true; + await account.signOut(localOnly); + + // user should be undefined after sign out + result = await account.getSignedInUser(); + Assert.equal(result, null); +}); + +add_task(async function test_set_signed_in_user_signs_out_previous_account() { + _("Check setSignedInUser signs out the previous account."); + let signOutServerCalled = false; + let credentials = { + email: "foo@example.com", + uid: "1234567890abcdef1234567890abcdef", + assertion: "foobar", + sessionToken: "dead", + verified: true, + ...MOCK_ACCOUNT_KEYS, + }; + let account = await MakeFxAccounts({ credentials }); + + account._internal._signOutServer = () => { + signOutServerCalled = true; + return Promise.resolve(true); + }; + + await account._internal.setSignedInUser(credentials); + Assert.ok(signOutServerCalled); +}); + +add_task(async function test_update_account_data() { + _("Check updateUserAccountData does the right thing."); + let credentials = { + email: "foo@example.com", + uid: "1234567890abcdef1234567890abcdef", + assertion: "foobar", + sessionToken: "dead", + verified: true, + ...MOCK_ACCOUNT_KEYS, + }; + let account = await MakeFxAccounts({ credentials }); + + let newCreds = { + email: credentials.email, + uid: credentials.uid, + assertion: "new_assertion", + }; + await account._internal.updateUserAccountData(newCreds); + Assert.equal( + (await account._internal.getUserAccountData()).assertion, + "new_assertion", + "new field value was saved" + ); + + // but we should fail attempting to change the uid. + newCreds = { + email: credentials.email, + uid: "11111111111111111111222222222222", + assertion: "new_assertion", + }; + await Assert.rejects( + account._internal.updateUserAccountData(newCreds), + /The specified credentials aren't for the current user/ + ); + + // should fail without the uid. + newCreds = { + assertion: "new_assertion", + }; + await Assert.rejects( + account._internal.updateUserAccountData(newCreds), + /The specified credentials have no uid/ + ); + + // and should fail with a field name that's not known by storage. + newCreds = { + email: credentials.email, + uid: "11111111111111111111222222222222", + foo: "bar", + }; + await Assert.rejects( + account._internal.updateUserAccountData(newCreds), + /The specified credentials aren't for the current user/ + ); +}); + +add_task(async function test_getCertificateOffline() { + _("getCertificateOffline()"); + let credentials = { + email: "foo@example.com", + uid: "1234567890abcdef1234567890abcdef", + sessionToken: "dead", + verified: true, + }; + let fxa = await MakeFxAccounts({ credentials }); + // Test that an expired cert throws if we're offline. + let offline = Services.io.offline; + Services.io.offline = true; + await fxa._internal + .getKeypairAndCertificate(fxa._internal.currentAccountState) + .then( + result => { + Services.io.offline = offline; + do_throw("Unexpected success"); + }, + err => { + Services.io.offline = offline; + // ... so we have to check the error string. + Assert.equal(err, "Error: OFFLINE"); + } + ); + await fxa.signOut(/* localOnly = */ true); +}); + +add_task(async function test_getCertificateCached() { + _("getCertificateCached()"); + let credentials = { + email: "foo@example.com", + uid: "1234567890abcdef1234567890abcdef", + sessionToken: "dead", + verified: true, + // A cached keypair and cert that remain valid. + keyPair: { + validUntil: Date.now() + KEY_LIFETIME + 10000, + rawKeyPair: "good-keypair", + }, + cert: { + validUntil: Date.now() + CERT_LIFETIME + 10000, + rawCert: "good-cert", + }, + }; + let fxa = await MakeFxAccounts({ credentials }); + + let { keyPair, certificate } = await fxa._internal.getKeypairAndCertificate( + fxa._internal.currentAccountState + ); + // should have the same keypair and cert. + Assert.equal(keyPair, credentials.keyPair.rawKeyPair); + Assert.equal(certificate, credentials.cert.rawCert); + await fxa.signOut(/* localOnly = */ true); +}); + +add_task(async function test_getCertificateExpiredCert() { + _("getCertificateExpiredCert()"); + let credentials = { + email: "foo@example.com", + uid: "1234567890abcdef1234567890abcdef", + sessionToken: "dead", + verified: true, + // A cached keypair that remains valid. + keyPair: { + validUntil: Date.now() + KEY_LIFETIME + 10000, + rawKeyPair: "good-keypair", + }, + // A cached certificate which has expired. + cert: { + validUntil: Date.parse("Mon, 13 Jan 2000 21:45:06 GMT"), + rawCert: "expired-cert", + }, + }; + let fxa = await MakeFxAccounts({ + internal: { + getCertificateSigned() { + return "new cert"; + }, + }, + credentials, + }); + let { keyPair, certificate } = await fxa._internal.getKeypairAndCertificate( + fxa._internal.currentAccountState + ); + // should have the same keypair but a new cert. + Assert.equal(keyPair, credentials.keyPair.rawKeyPair); + Assert.notEqual(certificate, credentials.cert.rawCert); + await fxa.signOut(/* localOnly = */ true); +}); + +add_task(async function test_getCertificateExpiredKeypair() { + _("getCertificateExpiredKeypair()"); + let credentials = { + email: "foo@example.com", + uid: "1234567890abcdef", + sessionToken: "dead", + verified: true, + // A cached keypair that has expired. + keyPair: { + validUntil: Date.now() - 1000, + rawKeyPair: "expired-keypair", + }, + // A cached certificate which remains valid. + cert: { + validUntil: Date.now() + CERT_LIFETIME + 10000, + rawCert: "expired-cert", + }, + }; + let fxa = await MakeFxAccounts({ + internal: { + getCertificateSigned() { + return "new cert"; + }, + }, + credentials, + }); + let { keyPair, certificate } = await fxa._internal.getKeypairAndCertificate( + fxa._internal.currentAccountState + ); + // even though the cert was valid, the fact the keypair was not means we + // should have fetched both. + Assert.notEqual(keyPair, credentials.keyPair.rawKeyPair); + Assert.notEqual(certificate, credentials.cert.rawCert); + await fxa.signOut(/* localOnly = */ true); +}); + +// Sanity-check that our mocked client is working correctly +add_test(function test_client_mock() { + let fxa = new MockFxAccounts(); + let client = fxa._internal.fxAccountsClient; + Assert.equal(client._verified, false); + Assert.equal(typeof client.signIn, "function"); + + // The recoveryEmailStatus function eventually fulfills its promise + client.recoveryEmailStatus().then(response => { + Assert.equal(response.verified, false); + run_next_test(); + }); +}); + +// Sign in a user, and after a little while, verify the user's email. +// Right after signing in the user, we should get the 'onlogin' notification. +// Polling should detect that the email is verified, and eventually +// 'onverified' should be observed +add_test(function test_verification_poll() { + let fxa = new MockFxAccounts(); + let test_user = getTestUser("francine"); + let login_notification_received = false; + + makeObserver(ONVERIFIED_NOTIFICATION, function() { + log.debug("test_verification_poll observed onverified"); + // Once email verification is complete, we will observe onverified + fxa._internal.getUserAccountData().then(user => { + // And confirm that the user's state has changed + Assert.equal(user.verified, true); + Assert.equal(user.email, test_user.email); + Assert.ok(login_notification_received); + run_next_test(); + }); + }); + + makeObserver(ONLOGIN_NOTIFICATION, function() { + log.debug("test_verification_poll observer onlogin"); + login_notification_received = true; + }); + + fxa.setSignedInUser(test_user).then(() => { + fxa._internal.getUserAccountData().then(user => { + // The user is signing in, but email has not been verified yet + Assert.equal(user.verified, false); + do_timeout(200, function() { + log.debug("Mocking verification of francine's email"); + fxa._internal.fxAccountsClient._email = test_user.email; + fxa._internal.fxAccountsClient._verified = true; + }); + }); + }); +}); + +// Sign in the user, but never verify the email. The check-email +// poll should time out. No verifiedlogin event should be observed, and the +// internal whenVerified promise should be rejected +add_test(function test_polling_timeout() { + // This test could be better - the onverified observer might fire on + // somebody else's stack, and we're not making sure that we're not receiving + // such a message. In other words, this tests either failure, or success, but + // not both. + + let fxa = new MockFxAccounts(); + let test_user = getTestUser("carol"); + + let removeObserver = makeObserver(ONVERIFIED_NOTIFICATION, function() { + do_throw("We should not be getting a login event!"); + }); + + fxa._internal.POLL_SESSION = 1; + + let p = fxa._internal.whenVerified({}); + + fxa.setSignedInUser(test_user).then(() => { + p.then( + success => { + do_throw("this should not succeed"); + }, + fail => { + removeObserver(); + fxa.signOut().then(run_next_test); + } + ); + }); +}); + +// For bug 1585299 - ensure we only get a single ONVERIFIED notification. +add_task(async function test_onverified_once() { + let fxa = new MockFxAccounts(); + let user = getTestUser("francine"); + + let numNotifications = 0; + + function observe(aSubject, aTopic, aData) { + numNotifications += 1; + } + Services.obs.addObserver(observe, ONVERIFIED_NOTIFICATION); + + fxa._internal.POLL_SESSION = 1; + + await fxa.setSignedInUser(user); + + Assert.ok(!(await fxa.getSignedInUser()).verified, "starts unverified"); + + await fxa._internal.startPollEmailStatus( + fxa._internal.currentAccountState, + user.sessionToken, + "start" + ); + + Assert.ok(!(await fxa.getSignedInUser()).verified, "still unverified"); + + log.debug("Mocking verification of francine's email"); + fxa._internal.fxAccountsClient._email = user.email; + fxa._internal.fxAccountsClient._verified = true; + + await fxa._internal.startPollEmailStatus( + fxa._internal.currentAccountState, + user.sessionToken, + "again" + ); + + Assert.ok((await fxa.getSignedInUser()).verified, "now verified"); + + Assert.equal(numNotifications, 1, "expect exactly 1 ONVERIFIED"); + + Services.obs.removeObserver(observe, ONVERIFIED_NOTIFICATION); + await fxa.signOut(); +}); + +add_test(function test_pollEmailStatus_start_verified() { + let fxa = new MockFxAccounts(); + let test_user = getTestUser("carol"); + + fxa._internal.POLL_SESSION = 20 * 60000; + fxa._internal.VERIFICATION_POLL_TIMEOUT_INITIAL = 50000; + + fxa.setSignedInUser(test_user).then(() => { + fxa._internal.getUserAccountData().then(user => { + fxa._internal.fxAccountsClient._email = test_user.email; + fxa._internal.fxAccountsClient._verified = true; + const mock = sinon.mock(fxa._internal); + mock.expects("_scheduleNextPollEmailStatus").never(); + fxa._internal + .startPollEmailStatus( + fxa._internal.currentAccountState, + user.sessionToken, + "start" + ) + .then(() => { + mock.verify(); + mock.restore(); + run_next_test(); + }); + }); + }); +}); + +add_test(function test_pollEmailStatus_start() { + let fxa = new MockFxAccounts(); + let test_user = getTestUser("carol"); + + fxa._internal.POLL_SESSION = 20 * 60000; + fxa._internal.VERIFICATION_POLL_TIMEOUT_INITIAL = 123456; + + fxa.setSignedInUser(test_user).then(() => { + fxa._internal.getUserAccountData().then(user => { + const mock = sinon.mock(fxa._internal); + mock + .expects("_scheduleNextPollEmailStatus") + .once() + .withArgs( + fxa._internal.currentAccountState, + user.sessionToken, + 123456, + "start" + ); + fxa._internal + .startPollEmailStatus( + fxa._internal.currentAccountState, + user.sessionToken, + "start" + ) + .then(() => { + mock.verify(); + mock.restore(); + run_next_test(); + }); + }); + }); +}); + +add_test(function test_pollEmailStatus_start_subsequent() { + let fxa = new MockFxAccounts(); + let test_user = getTestUser("carol"); + + fxa._internal.POLL_SESSION = 20 * 60000; + fxa._internal.VERIFICATION_POLL_TIMEOUT_INITIAL = 123456; + fxa._internal.VERIFICATION_POLL_TIMEOUT_SUBSEQUENT = 654321; + fxa._internal.VERIFICATION_POLL_START_SLOWDOWN_THRESHOLD = -1; + + fxa.setSignedInUser(test_user).then(() => { + fxa._internal.getUserAccountData().then(user => { + const mock = sinon.mock(fxa._internal); + mock + .expects("_scheduleNextPollEmailStatus") + .once() + .withArgs( + fxa._internal.currentAccountState, + user.sessionToken, + 654321, + "start" + ); + fxa._internal + .startPollEmailStatus( + fxa._internal.currentAccountState, + user.sessionToken, + "start" + ) + .then(() => { + mock.verify(); + mock.restore(); + run_next_test(); + }); + }); + }); +}); + +add_test(function test_pollEmailStatus_browser_startup() { + let fxa = new MockFxAccounts(); + let test_user = getTestUser("carol"); + + fxa._internal.POLL_SESSION = 20 * 60000; + fxa._internal.VERIFICATION_POLL_TIMEOUT_SUBSEQUENT = 654321; + + fxa.setSignedInUser(test_user).then(() => { + fxa._internal.getUserAccountData().then(user => { + const mock = sinon.mock(fxa._internal); + mock + .expects("_scheduleNextPollEmailStatus") + .once() + .withArgs( + fxa._internal.currentAccountState, + user.sessionToken, + 654321, + "browser-startup" + ); + fxa._internal + .startPollEmailStatus( + fxa._internal.currentAccountState, + user.sessionToken, + "browser-startup" + ) + .then(() => { + mock.verify(); + mock.restore(); + run_next_test(); + }); + }); + }); +}); + +add_test(function test_pollEmailStatus_push() { + let fxa = new MockFxAccounts(); + let test_user = getTestUser("carol"); + + fxa.setSignedInUser(test_user).then(() => { + fxa._internal.getUserAccountData().then(user => { + const mock = sinon.mock(fxa._internal); + mock.expects("_scheduleNextPollEmailStatus").never(); + fxa._internal + .startPollEmailStatus( + fxa._internal.currentAccountState, + user.sessionToken, + "push" + ) + .then(() => { + mock.verify(); + mock.restore(); + run_next_test(); + }); + }); + }); +}); + +add_test(function test_getKeyForScope() { + let fxa = new MockFxAccounts(); + let user = getTestUser("eusebius"); + + // Once email has been verified, we will be able to get keys + user.verified = true; + + fxa.setSignedInUser(user).then(() => { + fxa._internal.getUserAccountData().then(user2 => { + // Before getKeyForScope, we have no keys + Assert.equal(!!user2.scopedKeys, false); + Assert.equal(!!user2.kSync, false); + Assert.equal(!!user2.kXCS, false); + Assert.equal(!!user2.kExtSync, false); + Assert.equal(!!user2.kExtKbHash, false); + Assert.equal(!!user2.ecosystemUserId, false); + // And we still have a key-fetch token and unwrapBKey to use + Assert.equal(!!user2.keyFetchToken, true); + Assert.equal(!!user2.unwrapBKey, true); + + fxa.keys.getKeyForScope(SCOPE_OLD_SYNC).then(() => { + fxa._internal.getUserAccountData().then(user3 => { + // Now we should have keys + Assert.equal(fxa._internal.isUserEmailVerified(user3), true); + Assert.equal(!!user3.verified, true); + Assert.notEqual(null, user3.scopedKeys); + Assert.notEqual(null, user3.kSync); + Assert.notEqual(null, user3.kXCS); + Assert.notEqual(null, user3.kExtSync); + Assert.notEqual(null, user3.kExtKbHash); + Assert.notEqual(null, user3.ecosystemUserId); + Assert.equal(user3.keyFetchToken, undefined); + Assert.equal(user3.unwrapBKey, undefined); + run_next_test(); + }); + }); + }); + }); +}); + +add_task(async function test_getKeyForScope_kb_migration() { + let fxa = new MockFxAccounts(); + let user = getTestUser("eusebius"); + + user.verified = true; + // Set-up the deprecated set of keys. + user.kA = "e0245ab7f10e483470388e0a28f0a03379a3b417174fb2b42feab158b4ac2dbd"; + user.kB = "eaf9570b7219a4187d3d6bf3cec2770c2e0719b7cc0dfbb38243d6f1881675e9"; + + await fxa.setSignedInUser(user); + await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC); + let newUser = await fxa._internal.getUserAccountData(); + Assert.equal(newUser.kA, null); + Assert.equal(newUser.kB, null); + Assert.deepEqual(newUser.scopedKeys, { + "https://identity.mozilla.com/apps/oldsync": { + kid: "1234567890123-IqQv4onc7VcVE1kTQkyyOw", + k: + "DW_ll5GwX6SJ5GPqJVAuMUP2t6kDqhUulc2cbt26xbTcaKGQl-9l29FHAQ7kUiJETma4s9fIpEHrt909zgFang", + kty: "oct", + }, + "https://identity.mozilla.com/ids/ecosystem_telemetry": { + kid: "1234567890-ruhbB-qilFS-9bwxlCe4Qw", + k: "niMTzlPWb01A2nkO4SkEAUalO7FiQ61yq69X6b8V08Y", + kty: "oct", + }, + "sync:addon_storage": { + kid: "1234567890123-pBOR6B6JulbJr3BxKVOqIU4Cq_WAjFp4ApLn5NRVARE", + k: + "ut7VPrNYfXkA5gTopo2GCr-d4wtclV08TV26Y_Jv2IJlzYWSP26dzRau87gryIA5qJxZ7NnojeCadBjH2U-QyQ", + kty: "oct", + }, + }); + // These hex values were manually confirmed to be equivalent to the b64 values above. + Assert.equal( + newUser.kSync, + "0d6fe59791b05fa489e463ea25502e3143f6b7a903aa152e95cd9c6eddbac5b4" + + "dc68a19097ef65dbd147010ee45222444e66b8b3d7c8a441ebb7dd3dce015a9e" + ); + Assert.equal(newUser.kXCS, "22a42fe289dced5715135913424cb23b"); + Assert.equal( + newUser.kExtSync, + "baded53eb3587d7900e604e8a68d860abf9de30b5c955d3c4d5dba63f26fd882" + + "65cd85923f6e9dcd16aef3b82bc88039a89c59ecd9e88de09a7418c7d94f90c9" + ); + Assert.equal( + newUser.kExtKbHash, + "a41391e81e89ba56c9af70712953aa214e02abf5808c5a780292e7e4d4550111" + ); + Assert.equal( + newUser.ecosystemUserId, + "9e2313ce53d66f4d40da790ee129040146a53bb16243ad72abaf57e9bf15d3c6" + ); +}); + +add_task(async function test_getKeyForScope_scopedKeys_migration() { + let fxa = new MockFxAccounts(); + let user = getTestUser("eusebius"); + + user.verified = true; + // Set-up the keys in deprecated fields. + user.kSync = MOCK_ACCOUNT_KEYS.kSync; + user.kXCS = MOCK_ACCOUNT_KEYS.kXCS; + user.kExtSync = MOCK_ACCOUNT_KEYS.kExtSync; + user.kExtKbHash = MOCK_ACCOUNT_KEYS.kExtKbHash; + Assert.equal(user.ecosystemUserId, null); + Assert.equal(user.scopedKeys, null); + + await fxa.setSignedInUser(user); + await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC); + let newUser = await fxa._internal.getUserAccountData(); + Assert.equal(newUser.kA, null); + Assert.equal(newUser.kB, null); + // It should have correctly formatted the corresponding scoped keys, + // but failed to magic the ecosystem-telemetry key out of nowhere. + const expectedScopedKeys = { ...MOCK_ACCOUNT_KEYS.scopedKeys }; + delete expectedScopedKeys[SCOPE_ECOSYSTEM_TELEMETRY]; + Assert.deepEqual(newUser.scopedKeys, expectedScopedKeys); + // And left the existing key fields unchanged. + Assert.equal(newUser.kSync, user.kSync); + Assert.equal(newUser.kXCS, user.kXCS); + Assert.equal(newUser.kExtSync, user.kExtSync); + Assert.equal(newUser.kExtKbHash, user.kExtKbHash); + Assert.equal(user.ecosystemUserId, null); +}); + +add_task(async function test_getKeyForScope_nonexistent_account() { + let fxa = new MockFxAccounts(); + let bismarck = getTestUser("bismarck"); + + let client = fxa._internal.fxAccountsClient; + client.accountStatus = () => Promise.resolve(false); + client.sessionStatus = () => Promise.resolve(false); + client.accountKeys = () => { + return Promise.reject({ + code: 401, + errno: ERRNO_INVALID_AUTH_TOKEN, + }); + }; + + await fxa.setSignedInUser(bismarck); + + let promiseLogout = new Promise(resolve => { + makeObserver(ONLOGOUT_NOTIFICATION, function() { + log.debug("test_getKeyForScope_nonexistent_account observed logout"); + resolve(); + }); + }); + + // XXX - the exception message here isn't ideal, but doesn't really matter... + await Assert.rejects( + fxa.keys.getKeyForScope(SCOPE_OLD_SYNC), + /A different user signed in/ + ); + + await promiseLogout; + + let user = await fxa._internal.getUserAccountData(); + Assert.equal(user, null); +}); + +// getKeyForScope with invalid keyFetchToken should delete keyFetchToken from storage +add_task(async function test_getKeyForScope_invalid_token() { + let fxa = new MockFxAccounts(); + let yusuf = getTestUser("yusuf"); + + let client = fxa._internal.fxAccountsClient; + client.accountStatus = () => Promise.resolve(true); // account exists. + client.sessionStatus = () => Promise.resolve(false); // session is invalid. + client.accountKeys = () => { + return Promise.reject({ + code: 401, + errno: ERRNO_INVALID_AUTH_TOKEN, + }); + }; + + await fxa.setSignedInUser(yusuf); + + try { + await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC); + Assert.ok(false); + } catch (err) { + Assert.equal(err.code, 401); + Assert.equal(err.errno, ERRNO_INVALID_AUTH_TOKEN); + } + + let user = await fxa._internal.getUserAccountData(); + Assert.equal(user.email, yusuf.email); + Assert.equal(user.keyFetchToken, null); + await fxa._internal.abortExistingFlow(); +}); + +// This is the exact same test vectors as +// https://github.com/mozilla/fxa-crypto-relier/blob/f94f441159029a645a474d4b6439c38308da0bb0/test/deriver/ScopedKeys.js#L58 +add_task(async function test_getKeyForScope_oldsync() { + let fxa = new MockFxAccounts(); + let client = fxa._internal.fxAccountsClient; + client.getScopedKeyData = () => + Promise.resolve({ + "https://identity.mozilla.com/apps/oldsync": { + identifier: "https://identity.mozilla.com/apps/oldsync", + keyRotationSecret: + "0000000000000000000000000000000000000000000000000000000000000000", + keyRotationTimestamp: 1510726317123, + }, + }); + let user = { + ...getTestUser("eusebius"), + uid: "aeaa1725c7a24ff983c6295725d5fc9b", + kB: "eaf9570b7219a4187d3d6bf3cec2770c2e0719b7cc0dfbb38243d6f1881675e9", + verified: true, + }; + await fxa.setSignedInUser(user); + const key = await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC); + Assert.deepEqual(key, { + scope: SCOPE_OLD_SYNC, + kid: "1510726317123-IqQv4onc7VcVE1kTQkyyOw", + k: + "DW_ll5GwX6SJ5GPqJVAuMUP2t6kDqhUulc2cbt26xbTcaKGQl-9l29FHAQ7kUiJETma4s9fIpEHrt909zgFang", + kty: "oct", + }); +}); + +add_task(async function test_getScopedKeys_unavailable_scope() { + let fxa = new MockFxAccounts(); + let user = { + ...getTestUser("eusebius"), + uid: "aeaa1725c7a24ff983c6295725d5fc9b", + verified: true, + ...MOCK_ACCOUNT_KEYS, + }; + await fxa.setSignedInUser(user); + await Assert.rejects( + fxa.keys.getKeyForScope("otherkeybearingscope"), + /Key not available for scope/ + ); +}); + +add_task(async function test_getScopedKeys_misconfigured_fxa_server() { + let fxa = new MockFxAccounts(); + let client = fxa._internal.fxAccountsClient; + client.getScopedKeyData = () => + Promise.resolve({ + wrongscope: { + identifier: "wrongscope", + keyRotationSecret: + "0000000000000000000000000000000000000000000000000000000000000000", + keyRotationTimestamp: 1510726331712, + }, + }); + let user = { + ...getTestUser("eusebius"), + uid: "aeaa1725c7a24ff983c6295725d5fc9b", + verified: true, + kSync: + "0d6fe59791b05fa489e463ea25502e3143f6b7a903aa152e95cd9c6eddbac5b4dc68a19097ef65dbd147010ee45222444e66b8b3d7c8a441ebb7dd3dce015a9e", + kXCS: "22a42fe289dced5715135913424cb23b", + kExtSync: + "baded53eb3587d7900e604e8a68d860abf9de30b5c955d3c4d5dba63f26fd88265cd85923f6e9dcd16aef3b82bc88039a89c59ecd9e88de09a7418c7d94f90c9", + kExtKbHash: + "b776a89db29f22daedd154b44ff88397d0b210223fb956f5a749521dd8de8ddf", + }; + await fxa.setSignedInUser(user); + await Assert.rejects( + fxa.keys.getKeyForScope(SCOPE_OLD_SYNC), + /The FxA server did not grant Firefox the `oldsync` scope/ + ); +}); + +// _fetchAndUnwrapAndDeriveKeys with no keyFetchToken should trigger signOut +// XXX - actually, it probably shouldn't - bug 1572313. +add_test(function test_fetchAndUnwrapAndDeriveKeys_no_token() { + let fxa = new MockFxAccounts(); + let user = getTestUser("lettuce.protheroe"); + delete user.keyFetchToken; + + makeObserver(ONLOGOUT_NOTIFICATION, function() { + log.debug("test_fetchAndUnwrapKeys_no_token observed logout"); + fxa._internal.getUserAccountData().then(user2 => { + fxa._internal.abortExistingFlow().then(run_next_test); + }); + }); + + fxa + .setSignedInUser(user) + .then(user2 => { + return fxa.keys._fetchAndUnwrapAndDeriveKeys(); + }) + .catch(error => { + log.info("setSignedInUser correctly rejected"); + }); +}); + +// Alice (User A) signs up but never verifies her email. Then Bob (User B) +// signs in with a verified email. Ensure that no sign-in events are triggered +// on Alice's behalf. In the end, Bob should be the signed-in user. +add_test(function test_overlapping_signins() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + let bob = getTestUser("bob"); + + makeObserver(ONVERIFIED_NOTIFICATION, function() { + log.debug("test_overlapping_signins observed onverified"); + // Once email verification is complete, we will observe onverified + fxa._internal.getUserAccountData().then(user => { + Assert.equal(user.email, bob.email); + Assert.equal(user.verified, true); + run_next_test(); + }); + }); + + // Alice is the user signing in; her email is unverified. + fxa.setSignedInUser(alice).then(() => { + log.debug("Alice signing in ..."); + fxa._internal.getUserAccountData().then(user => { + Assert.equal(user.email, alice.email); + Assert.equal(user.verified, false); + log.debug("Alice has not verified her email ..."); + + // Now Bob signs in instead and actually verifies his email + log.debug("Bob signing in ..."); + fxa.setSignedInUser(bob).then(() => { + do_timeout(200, function() { + // Mock email verification ... + log.debug("Bob verifying his email ..."); + fxa._internal.fxAccountsClient._verified = true; + }); + }); + }); + }); +}); + +add_task(async function test_getAssertion_invalid_token() { + let fxa = new MockFxAccounts(); + + let client = fxa._internal.fxAccountsClient; + client.accountStatus = () => Promise.resolve(true); + client.sessionStatus = () => Promise.resolve(false); + + let creds = { + sessionToken: "sessionToken", + verified: true, + email: "sonia@example.com", + ...MOCK_ACCOUNT_KEYS, + }; + await fxa.setSignedInUser(creds); + // we have what we still believe to be a valid session token, so we should + // consider that we have a local session. + Assert.ok(await fxa.hasLocalSession()); + + try { + let promiseAssertion = fxa._internal.getAssertion("audience.example.com"); + fxa._internal._d_signCertificate.reject({ + code: 401, + errno: ERRNO_INVALID_AUTH_TOKEN, + }); + await promiseAssertion; + Assert.ok(false, "getAssertion should reject invalid session token"); + } catch (err) { + Assert.equal(err.code, 401); + Assert.equal(err.errno, ERRNO_INVALID_AUTH_TOKEN); + } + + let user = await fxa._internal.getUserAccountData(); + Assert.equal(user.email, creds.email); + Assert.equal(user.sessionToken, null); + Assert.ok(!(await fxa.hasLocalSession())); +}); + +add_task(async function test_getAssertion() { + let fxa = new MockFxAccounts(); + + let creds = { + sessionToken: "sessionToken", + verified: true, + ...MOCK_ACCOUNT_KEYS, + }; + // By putting scopedKeys in "creds", we skip ahead to the "we're ready" stage. + await fxa.setSignedInUser(creds); + + _("== ready to go\n"); + // Start with a nice arbitrary but realistic date. Here we use a nice RFC + // 1123 date string like we would get from an HTTP header. Over the course of + // the test, we will update 'now', but leave 'start' where it is. + let now = Date.parse("Mon, 13 Jan 2014 21:45:06 GMT"); + let start = now; + fxa._internal._now_is = now; + + let d = fxa._internal.getAssertion("audience.example.com"); + // At this point, a thread has been spawned to generate the keys. + _("-- back from fxa.getAssertion\n"); + fxa._internal._d_signCertificate.resolve("cert1"); + let assertion = await d; + Assert.equal(fxa._internal._getCertificateSigned_calls.length, 1); + Assert.equal(fxa._internal._getCertificateSigned_calls[0][0], "sessionToken"); + Assert.notEqual(assertion, null); + _("ASSERTION: " + assertion + "\n"); + let pieces = assertion.split("~"); + Assert.equal(pieces[0], "cert1"); + let userData = await fxa._internal.getUserAccountData(); + let keyPair = userData.keyPair; + let cert = userData.cert; + Assert.notEqual(keyPair, undefined); + _(keyPair.validUntil + "\n"); + let p2 = pieces[1].split("."); + let header = JSON.parse(atob(p2[0])); + _("HEADER: " + JSON.stringify(header) + "\n"); + Assert.equal(header.alg, "DS128"); + let payload = JSON.parse(atob(p2[1])); + _("PAYLOAD: " + JSON.stringify(payload) + "\n"); + Assert.equal(payload.aud, "audience.example.com"); + Assert.equal(keyPair.validUntil, start + KEY_LIFETIME); + Assert.equal(cert.validUntil, start + CERT_LIFETIME); + _("delta: " + Date.parse(payload.exp - start) + "\n"); + let exp = Number(payload.exp); + + Assert.equal(exp, now + ASSERTION_LIFETIME); + + // Reset for next call. + fxa._internal._d_signCertificate = PromiseUtils.defer(); + + // Getting a new assertion "soon" (i.e., w/o incrementing "now"), even for + // a new audience, should not provoke key generation or a signing request. + assertion = await fxa._internal.getAssertion("other.example.com"); + + // There were no additional calls - same number of getcert calls as before + Assert.equal(fxa._internal._getCertificateSigned_calls.length, 1); + + // Wait an hour; assertion use period expires, but not the certificate + now += ONE_HOUR_MS; + fxa._internal._now_is = now; + + // This won't block on anything - will make an assertion, but not get a + // new certificate. + assertion = await fxa._internal.getAssertion("third.example.com"); + + // Test will time out if that failed (i.e., if that had to go get a new cert) + pieces = assertion.split("~"); + Assert.equal(pieces[0], "cert1"); + p2 = pieces[1].split("."); + header = JSON.parse(atob(p2[0])); + payload = JSON.parse(atob(p2[1])); + Assert.equal(payload.aud, "third.example.com"); + + // The keypair and cert should have the same validity as before, but the + // expiration time of the assertion should be different. We compare this to + // the initial start time, to which they are relative, not the current value + // of "now". + userData = await fxa._internal.getUserAccountData(); + + keyPair = userData.keyPair; + cert = userData.cert; + Assert.equal(keyPair.validUntil, start + KEY_LIFETIME); + Assert.equal(cert.validUntil, start + CERT_LIFETIME); + exp = Number(payload.exp); + Assert.equal(exp, now + ASSERTION_LIFETIME); + + // Now we wait even longer, and expect both assertion and cert to expire. So + // we will have to get a new keypair and cert. + now += ONE_DAY_MS; + fxa._internal._now_is = now; + d = fxa._internal.getAssertion("fourth.example.com"); + fxa._internal._d_signCertificate.resolve("cert2"); + assertion = await d; + Assert.equal(fxa._internal._getCertificateSigned_calls.length, 2); + Assert.equal(fxa._internal._getCertificateSigned_calls[1][0], "sessionToken"); + pieces = assertion.split("~"); + Assert.equal(pieces[0], "cert2"); + p2 = pieces[1].split("."); + header = JSON.parse(atob(p2[0])); + payload = JSON.parse(atob(p2[1])); + Assert.equal(payload.aud, "fourth.example.com"); + userData = await fxa._internal.getUserAccountData(); + keyPair = userData.keyPair; + cert = userData.cert; + Assert.equal(keyPair.validUntil, now + KEY_LIFETIME); + Assert.equal(cert.validUntil, now + CERT_LIFETIME); + exp = Number(payload.exp); + + Assert.equal(exp, now + ASSERTION_LIFETIME); + _("----- DONE ----\n"); +}); + +add_task(async function test_resend_email_not_signed_in() { + let fxa = new MockFxAccounts(); + + try { + await fxa.resendVerificationEmail(); + } catch (err) { + Assert.equal(err.message, ERROR_NO_ACCOUNT); + return; + } + do_throw("Should not be able to resend email when nobody is signed in"); +}); + +add_task(async function test_accountStatus() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + + // If we have no user, we have no account server-side + let result = await fxa.checkAccountStatus(); + Assert.ok(!result); + // Set a user - the fxAccountsClient mock will say "ok". + await fxa.setSignedInUser(alice); + result = await fxa.checkAccountStatus(); + Assert.ok(result); + // flag the item as deleted on the server. + fxa._internal.fxAccountsClient._deletedOnServer = true; + result = await fxa.checkAccountStatus(); + Assert.ok(!result); + fxa._internal.fxAccountsClient._deletedOnServer = false; + await fxa.signOut(); +}); + +add_task(async function test_resend_email_invalid_token() { + let fxa = new MockFxAccounts(); + let sophia = getTestUser("sophia"); + Assert.notEqual(sophia.sessionToken, null); + + let client = fxa._internal.fxAccountsClient; + client.resendVerificationEmail = () => { + return Promise.reject({ + code: 401, + errno: ERRNO_INVALID_AUTH_TOKEN, + }); + }; + // This test wants the account to exist but the local session invalid. + client.accountStatus = uid => { + Assert.ok(uid, "got a uid to check"); + return Promise.resolve(true); + }; + client.sessionStatus = token => { + Assert.ok(token, "got a token to check"); + return Promise.resolve(false); + }; + + await fxa.setSignedInUser(sophia); + let user = await fxa._internal.getUserAccountData(); + Assert.equal(user.email, sophia.email); + Assert.equal(user.verified, false); + log.debug("Sophia wants verification email resent"); + + try { + await fxa.resendVerificationEmail(); + Assert.ok( + false, + "resendVerificationEmail should reject invalid session token" + ); + } catch (err) { + Assert.equal(err.code, 401); + Assert.equal(err.errno, ERRNO_INVALID_AUTH_TOKEN); + } + + user = await fxa._internal.getUserAccountData(); + Assert.equal(user.email, sophia.email); + Assert.equal(user.sessionToken, null); + await fxa._internal.abortExistingFlow(); +}); + +add_test(function test_resend_email() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + + let initialState = fxa._internal.currentAccountState; + + // Alice is the user signing in; her email is unverified. + fxa.setSignedInUser(alice).then(() => { + log.debug("Alice signing in"); + + // We're polling for the first email + Assert.ok(fxa._internal.currentAccountState !== initialState); + let aliceState = fxa._internal.currentAccountState; + + // The polling timer is ticking + Assert.ok(fxa._internal.currentTimer > 0); + + fxa._internal.getUserAccountData().then(user => { + Assert.equal(user.email, alice.email); + Assert.equal(user.verified, false); + log.debug("Alice wants verification email resent"); + + fxa.resendVerificationEmail().then(result => { + // Mock server response; ensures that the session token actually was + // passed to the client to make the hawk call + Assert.equal(result, "alice's session token"); + + // Timer was not restarted + Assert.ok(fxa._internal.currentAccountState === aliceState); + + // Timer is still ticking + Assert.ok(fxa._internal.currentTimer > 0); + + // Ok abort polling before we go on to the next test + fxa._internal.abortExistingFlow(); + run_next_test(); + }); + }); + }); +}); + +Services.prefs.setCharPref( + "identity.fxaccounts.remote.oauth.uri", + "https://example.com/v1" +); + +add_test(function test_getOAuthToken() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + let oauthTokenCalled = false; + + fxa._internal._d_signCertificate.resolve("cert1"); + + let client = fxa._internal.fxAccountsOAuthGrantClient; + client.getTokenFromAssertion = () => { + oauthTokenCalled = true; + return Promise.resolve({ access_token: "token" }); + }; + + fxa.setSignedInUser(alice).then(() => { + fxa.getOAuthToken({ scope: "profile" }).then(result => { + Assert.ok(oauthTokenCalled); + Assert.equal(result, "token"); + run_next_test(); + }); + }); +}); + +add_test(async function test_getOAuthTokenWithSessionToken() { + Services.prefs.setBoolPref( + "identity.fxaccounts.useSessionTokensForOAuth", + true + ); + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + let oauthTokenCalled = false; + + let client = fxa._internal.fxAccountsClient; + client.accessTokenWithSessionToken = async ( + sessionTokenHex, + clientId, + scope, + ttl + ) => { + oauthTokenCalled = true; + Assert.equal(sessionTokenHex, "alice's session token"); + Assert.equal(clientId, "5882386c6d801776"); + Assert.equal(scope, "profile"); + Assert.equal(ttl, undefined); + return MOCK_TOKEN_RESPONSE; + }; + + await fxa.setSignedInUser(alice); + const result = await fxa.getOAuthToken({ scope: "profile" }); + Assert.ok(oauthTokenCalled); + Assert.equal(result, MOCK_TOKEN_RESPONSE.access_token); + Services.prefs.setBoolPref( + "identity.fxaccounts.useSessionTokensForOAuth", + false + ); + run_next_test(); +}); + +add_task(async function test_getOAuthTokenCachedWithSessionToken() { + Services.prefs.setBoolPref( + "identity.fxaccounts.useSessionTokensForOAuth", + true + ); + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + let numOauthTokenCalls = 0; + + let client = fxa._internal.fxAccountsClient; + client.accessTokenWithSessionToken = async () => { + numOauthTokenCalls++; + return MOCK_TOKEN_RESPONSE; + }; + + await fxa.setSignedInUser(alice); + let result = await fxa.getOAuthToken({ + scope: "profile", + service: "test-service", + }); + Assert.equal(numOauthTokenCalls, 1); + Assert.equal( + result, + "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69" + ); + + // requesting it again should not re-fetch the token. + result = await fxa.getOAuthToken({ + scope: "profile", + service: "test-service", + }); + Assert.equal(numOauthTokenCalls, 1); + Assert.equal( + result, + "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69" + ); + // But requesting the same service and a different scope *will* get a new one. + result = await fxa.getOAuthToken({ + scope: "something-else", + service: "test-service", + }); + Assert.equal(numOauthTokenCalls, 2); + Assert.equal( + result, + "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69" + ); + Services.prefs.setBoolPref( + "identity.fxaccounts.useSessionTokensForOAuth", + false + ); +}); + +add_test(function test_getOAuthTokenScoped() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + let oauthTokenCalled = false; + + fxa._internal._d_signCertificate.resolve("cert1"); + + let client = fxa._internal.fxAccountsOAuthGrantClient; + client.getTokenFromAssertion = (_assertion, scopeString) => { + equal(scopeString, "bar foo"); // scopes are sorted locally before request. + oauthTokenCalled = true; + return Promise.resolve({ access_token: "token" }); + }; + + fxa.setSignedInUser(alice).then(() => { + fxa.getOAuthToken({ scope: ["foo", "bar"] }).then(result => { + Assert.ok(oauthTokenCalled); + Assert.equal(result, "token"); + run_next_test(); + }); + }); +}); + +add_task(async function test_getOAuthTokenCached() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + let numOauthTokenCalls = 0; + + fxa._internal._d_signCertificate.resolve("cert1"); + + let client = fxa._internal.fxAccountsOAuthGrantClient; + client.getTokenFromAssertion = () => { + numOauthTokenCalls += 1; + return Promise.resolve({ access_token: "token" }); + }; + + await fxa.setSignedInUser(alice); + let result = await fxa.getOAuthToken({ + scope: "profile", + service: "test-service", + }); + Assert.equal(numOauthTokenCalls, 1); + Assert.equal(result, "token"); + + // requesting it again should not re-fetch the token. + result = await fxa.getOAuthToken({ + scope: "profile", + service: "test-service", + }); + Assert.equal(numOauthTokenCalls, 1); + Assert.equal(result, "token"); + // But requesting the same service and a different scope *will* get a new one. + result = await fxa.getOAuthToken({ + scope: "something-else", + service: "test-service", + }); + Assert.equal(numOauthTokenCalls, 2); + Assert.equal(result, "token"); +}); + +add_task(async function test_getOAuthTokenCachedScopeNormalization() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + let numOAuthTokenCalls = 0; + + fxa._internal._d_signCertificate.resolve("cert1"); + + let client = fxa._internal.fxAccountsOAuthGrantClient; + client.getTokenFromAssertion = () => { + numOAuthTokenCalls += 1; + return Promise.resolve({ access_token: "token" }); + }; + + await fxa.setSignedInUser(alice); + let result = await fxa.getOAuthToken({ + scope: ["foo", "bar"], + service: "test-service", + }); + Assert.equal(numOAuthTokenCalls, 1); + Assert.equal(result, "token"); + + // requesting it again with the scope array in a different order not re-fetch the token. + result = await fxa.getOAuthToken({ + scope: ["bar", "foo"], + service: "test-service", + }); + Assert.equal(numOAuthTokenCalls, 1); + Assert.equal(result, "token"); + // requesting it again with the scope array in different case not re-fetch the token. + result = await fxa.getOAuthToken({ + scope: ["Bar", "Foo"], + service: "test-service", + }); + Assert.equal(numOAuthTokenCalls, 1); + Assert.equal(result, "token"); + // But requesting with a new entry in the array does fetch one. + result = await fxa.getOAuthToken({ + scope: ["foo", "bar", "etc"], + service: "test-service", + }); + Assert.equal(numOAuthTokenCalls, 2); + Assert.equal(result, "token"); +}); + +add_test(function test_getOAuthToken_invalid_param() { + let fxa = new MockFxAccounts(); + + fxa.getOAuthToken().catch(err => { + Assert.equal(err.message, "INVALID_PARAMETER"); + fxa.signOut().then(run_next_test); + }); +}); + +add_test(function test_getOAuthToken_invalid_scope_array() { + let fxa = new MockFxAccounts(); + + fxa.getOAuthToken({ scope: [] }).catch(err => { + Assert.equal(err.message, "INVALID_PARAMETER"); + fxa.signOut().then(run_next_test); + }); +}); + +add_test(function test_getOAuthToken_misconfigure_oauth_uri() { + let fxa = new MockFxAccounts(); + + const prevServerURL = Services.prefs.getCharPref( + "identity.fxaccounts.remote.oauth.uri" + ); + Services.prefs.deleteBranch("identity.fxaccounts.remote.oauth.uri"); + + fxa.getOAuthToken().catch(err => { + Assert.equal(err.message, "INVALID_PARAMETER"); + // revert the pref + Services.prefs.setCharPref( + "identity.fxaccounts.remote.oauth.uri", + prevServerURL + ); + fxa.signOut().then(run_next_test); + }); +}); + +add_test(function test_getOAuthToken_no_account() { + let fxa = new MockFxAccounts(); + + fxa._internal.currentAccountState.getUserAccountData = function() { + return Promise.resolve(null); + }; + + fxa.getOAuthToken({ scope: "profile" }).catch(err => { + Assert.equal(err.message, "NO_ACCOUNT"); + fxa.signOut().then(run_next_test); + }); +}); + +add_test(function test_getOAuthToken_unverified() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + + fxa.setSignedInUser(alice).then(() => { + fxa.getOAuthToken({ scope: "profile" }).catch(err => { + Assert.equal(err.message, "UNVERIFIED_ACCOUNT"); + fxa.signOut().then(run_next_test); + }); + }); +}); + +add_test(function test_getOAuthToken_error() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + + fxa._internal._d_signCertificate.resolve("cert1"); + + let client = fxa._internal.fxAccountsOAuthGrantClient; + client.getTokenFromAssertion = () => { + return Promise.reject("boom"); + }; + + fxa.setSignedInUser(alice).then(() => { + fxa.getOAuthToken({ scope: "profile" }).catch(err => { + run_next_test(); + }); + }); +}); + +add_task(async function test_getOAuthToken_authErrorRefreshesCertificate() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + + fxa._internal._d_signCertificate.resolve("cert1"); + + let client = fxa._internal.fxAccountsOAuthGrantClient; + let numTokenCalls = 0; + client.getTokenFromAssertion = () => { + numTokenCalls++; + // First time around, reject with a 401. + if (numTokenCalls == 1) { + return Promise.reject({ + code: 401, + errno: 1104, + }); + } + // Second time around, succeed. + if (numTokenCalls == 2) { + return Promise.resolve({ access_token: "token" }); + } + throw new Error("too many token calls"); + }; + + await fxa.setSignedInUser(alice); + let result = await fxa.getOAuthToken({ scope: "profile" }); + + Assert.equal(result, "token"); + + Assert.equal(numTokenCalls, 2); + Assert.equal(fxa._internal._getCertificateSigned_calls.length, 2); +}); + +add_task(async function test_listAttachedOAuthClients() { + const ONE_HOUR = 60 * 60 * 1000; + const ONE_DAY = 24 * ONE_HOUR; + + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + + let client = fxa._internal.fxAccountsClient; + client.attachedClients = async () => { + return [ + // This entry was previously filtered but no longer is! + { + clientId: "a2270f727f45f648", + deviceId: "deadbeef", + sessionTokenId: null, + name: "Firefox Preview (no session token)", + scope: ["profile", "https://identity.mozilla.com/apps/oldsync"], + lastAccessTime: Date.now(), + }, + { + clientId: "802d56ef2a9af9fa", + deviceId: null, + sessionTokenId: null, + name: "Firefox Monitor", + scope: ["profile"], + lastAccessTime: Date.now() - ONE_DAY - ONE_HOUR, + }, + { + clientId: "1f30e32975ae5112", + deviceId: null, + sessionTokenId: null, + name: "Firefox Send", + scope: ["profile", "https://identity.mozilla.com/apps/send"], + lastAccessTime: Date.now() - ONE_DAY * 2 - ONE_HOUR, + }, + // One with a future date should be impossible, but having a negative + // result here would almost certainly confuse something! + { + clientId: "future-date", + deviceId: null, + sessionTokenId: null, + name: "Whatever", + lastAccessTime: Date.now() + ONE_DAY, + }, + // A missing/null lastAccessTime should end up with a missing lastAccessedDaysAgo + { + clientId: "missing-date", + deviceId: null, + sessionTokenId: null, + name: "Whatever", + }, + ]; + }; + + await fxa.setSignedInUser(alice); + const clients = await fxa.listAttachedOAuthClients(); + Assert.deepEqual(clients, [ + { + id: "a2270f727f45f648", + lastAccessedDaysAgo: 0, + }, + { + id: "802d56ef2a9af9fa", + lastAccessedDaysAgo: 1, + }, + { + id: "1f30e32975ae5112", + lastAccessedDaysAgo: 2, + }, + { + id: "future-date", + lastAccessedDaysAgo: 0, + }, + { + id: "missing-date", + lastAccessedDaysAgo: null, + }, + ]); +}); + +add_task(async function test_getSignedInUserProfile() { + let alice = getTestUser("alice"); + alice.verified = true; + + let mockProfile = { + getProfile() { + return Promise.resolve({ avatar: "image" }); + }, + tearDown() {}, + }; + let fxa = new FxAccounts({ + _signOutServer() { + return Promise.resolve(); + }, + device: { + _registerOrUpdateDevice() { + return Promise.resolve(); + }, + }, + }); + + await fxa._internal.setSignedInUser(alice); + fxa._internal._profile = mockProfile; + let result = await fxa.getSignedInUser(); + Assert.ok(!!result); + Assert.equal(result.avatar, "image"); +}); + +add_task(async function test_getSignedInUserProfile_error_uses_account_data() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + + fxa._internal.getSignedInUser = function() { + return Promise.resolve({ email: "foo@bar.com" }); + }; + fxa._internal._profile = { + getProfile() { + return Promise.reject("boom"); + }, + tearDown() { + teardownCalled = true; + }, + }; + + let teardownCalled = false; + await fxa.setSignedInUser(alice); + let result = await fxa.getSignedInUser(); + Assert.deepEqual(result.avatar, null); + await fxa.signOut(); + Assert.ok(teardownCalled); +}); + +add_task(async function test_checkVerificationStatusFailed() { + let fxa = new MockFxAccounts(); + let alice = getTestUser("alice"); + alice.verified = true; + + let client = fxa._internal.fxAccountsClient; + client.recoveryEmailStatus = () => { + return Promise.reject({ + code: 401, + errno: ERRNO_INVALID_AUTH_TOKEN, + }); + }; + client.accountStatus = () => Promise.resolve(true); + client.sessionStatus = () => Promise.resolve(false); + + await fxa.setSignedInUser(alice); + let user = await fxa._internal.getUserAccountData(); + Assert.notEqual(alice.sessionToken, null); + Assert.equal(user.email, alice.email); + Assert.equal(user.verified, true); + + await fxa._internal.checkVerificationStatus(); + + user = await fxa._internal.getUserAccountData(); + Assert.equal(user.email, alice.email); + Assert.equal(user.sessionToken, null); +}); + +add_task(async function test_deriveKeys() { + let account = await MakeFxAccounts(); + let kBhex = + "fd5c747806c07ce0b9d69dcfea144663e630b65ec4963596a22f24910d7dd15d"; + let kB = CommonUtils.hexToBytes(kBhex); + const uid = "1ad7f502-4cc7-4ec1-a209-071fd2fae348"; + + const { kSync, kXCS, kExtSync, kExtKbHash } = await account.keys._deriveKeys( + uid, + kB + ); + + Assert.equal( + kSync, + "ad501a50561be52b008878b2e0d8a73357778a712255f7722f497b5d4df14b05" + + "dc06afb836e1521e882f521eb34691d172337accdbf6e2a5b968b05a7bbb9885" + ); + Assert.equal(kXCS, "6ae94683571c7a7c54dab4700aa3995f"); + Assert.equal( + kExtSync, + "f5ccd9cfdefd9b1ac4d02c56964f59239d8dfa1ca326e63696982765c1352cdc" + + "5d78a5a9c633a6d25edfea0a6c221a3480332a49fd866f311c2e3508ddd07395" + ); + Assert.equal( + kExtKbHash, + "6192f1cc7dce95334455ba135fa1d8fca8f70e8f594ae318528de06f24ed0273" + ); +}); + +add_task(async function test_flushLogFile() { + _("Tests flushLogFile"); + let account = await MakeFxAccounts(); + let promiseObserved = new Promise(res => { + log.info("Adding flush-log-file observer."); + Services.obs.addObserver(function onFlushLogFile() { + Services.obs.removeObserver( + onFlushLogFile, + "service:log-manager:flush-log-file" + ); + res(); + }, "service:log-manager:flush-log-file"); + }); + account.flushLogFile(); + await promiseObserved; +}); + +/* + * End of tests. + * Utility functions follow. + */ + +function expandHex(two_hex) { + // Return a 64-character hex string, encoding 32 identical bytes. + let eight_hex = two_hex + two_hex + two_hex + two_hex; + let thirtytwo_hex = eight_hex + eight_hex + eight_hex + eight_hex; + return thirtytwo_hex + thirtytwo_hex; +} + +function expandBytes(two_hex) { + return CommonUtils.hexToBytes(expandHex(two_hex)); +} + +function getTestUser(name) { + return { + email: name + "@example.com", + uid: "1ad7f5024cc74ec1a209071fd2fae348", + sessionToken: name + "'s session token", + keyFetchToken: name + "'s keyfetch token", + unwrapBKey: expandHex("44"), + verified: false, + }; +} + +function makeObserver(aObserveTopic, aObserveFunc) { + let observer = { + // nsISupports provides type management in C++ + // nsIObserver is to be an observer + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + + observe(aSubject, aTopic, aData) { + log.debug("observed " + aTopic + " " + aData); + if (aTopic == aObserveTopic) { + removeMe(); + aObserveFunc(aSubject, aTopic, aData); + } + }, + }; + + function removeMe() { + log.debug("removing observer for " + aObserveTopic); + Services.obs.removeObserver(observer, aObserveTopic); + } + + Services.obs.addObserver(observer, aObserveTopic); + return removeMe; +} |