/* Any copyright is dedicated to the Public Domain. * http://creativecommons.org/publicdomain/zero/1.0/ */ "use strict"; const { CryptoUtils } = ChromeUtils.importESModule( "resource://services-crypto/utils.sys.mjs" ); const { FxAccounts } = ChromeUtils.importESModule( "resource://gre/modules/FxAccounts.sys.mjs" ); const { FxAccountsClient } = ChromeUtils.importESModule( "resource://gre/modules/FxAccountsClient.sys.mjs" ); const { ERRNO_INVALID_AUTH_TOKEN, ERROR_NO_ACCOUNT, FX_OAUTH_CLIENT_ID, ONLOGIN_NOTIFICATION, ONLOGOUT_NOTIFICATION, ONVERIFIED_NOTIFICATION, DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY, PREF_LAST_FXA_USER, } = ChromeUtils.importESModule( "resource://gre/modules/FxAccountsCommon.sys.mjs" ); // We grab some additional stuff via backstage passes. var { AccountState } = ChromeUtils.importESModule( "resource://gre/modules/FxAccounts.sys.mjs" ); 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.setStringPref("identity.fxaccounts.loglevel", "Trace"); Log.repository.getLogger("FirefoxAccounts").level = Log.Level.Trace; Services.prefs.setStringPref("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); 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, }, }); }); }); }; this.resendVerificationEmail = function (sessionToken) { // Return the session token to show that we received it in the first place return Promise.resolve(sessionToken); }; this.signOut = () => Promise.resolve(); FxAccountsClient.apply(this); } MockFxAccountsClient.prototype = {}; Object.setPrototypeOf( MockFxAccountsClient.prototype, 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: Promise.withResolvers(), _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); }, fxAccountsClient: new MockFxAccountsClient(), observerPreloads: [], device: { _registerOrUpdateDevice() {}, _checkRemoteCommandsUpdateNeeded: async () => false, }, 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(); internal.device._checkRemoteCommandsUpdateNeeded = async () => false; } } else { internal.device = { _registerOrUpdateDevice() {}, _checkRemoteCommandsUpdateNeeded: async () => false, }; } 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", 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.scopedKeys, 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.scopedKeys, credentials.scopedKeys); // 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", 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", sessionToken: "dead", verified: true, ...MOCK_ACCOUNT_KEYS, }; let account = await MakeFxAccounts({ credentials }); let newCreds = { email: credentials.email, uid: credentials.uid, sessionToken: "alive", }; await account._internal.updateUserAccountData(newCreds); Assert.equal( (await account._internal.getUserAccountData()).sessionToken, "alive", "new field value was saved" ); // but we should fail attempting to change the uid. newCreds = { email: credentials.email, uid: "11111111111111111111222222222222", sessionToken: "alive", }; await Assert.rejects( account._internal.updateUserAccountData(newCreds), /The specified credentials aren't for the current user/ ); // should fail without the uid. newCreds = { sessionToken: "alive", }; 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/ ); }); // 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.equal( Services.prefs.getStringPref(PREF_LAST_FXA_USER), CryptoUtils.sha256Base64(test_user.email) ); Assert.ok(login_notification_received); }) .finally(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); // 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.equal(user3.keyFetchToken, undefined); Assert.equal(user3.unwrapBKey, undefined); run_next_test(); }); }); }); }); }); add_task( async function test_getKeyForScope_scopedKeys_migration_removes_deprecated_high_level_keys() { let fxa = new MockFxAccounts(); let user = getTestUser("eusebius"); user.verified = true; // An account state with the deprecated kinto extension sync keys... user.kExtSync = "f5ccd9cfdefd9b1ac4d02c56964f59239d8dfa1ca326e63696982765c1352cdc" + "5d78a5a9c633a6d25edfea0a6c221a3480332a49fd866f311c2e3508ddd07395"; user.kExtKbHash = "6192f1cc7dce95334455ba135fa1d8fca8f70e8f594ae318528de06f24ed0273"; user.scopedKeys = { ...MOCK_ACCOUNT_KEYS.scopedKeys, }; await fxa.setSignedInUser(user); // getKeyForScope will run the migration await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC); let newUser = await fxa._internal.getUserAccountData(); // Then, the deprecated keys will be removed Assert.strictEqual(newUser.kExtSync, undefined); Assert.strictEqual(newUser.kExtKbHash, undefined); } ); add_task( async function test_getKeyForScope_scopedKeys_migration_removes_deprecated_scoped_keys() { let fxa = new MockFxAccounts(); let user = getTestUser("eusebius"); const DEPRECATED_SCOPE_WEBEXT_SYNC = "sync:addon_storage"; const EXTRA_SCOPE = "an unknown, but non-deprecated scope"; user.verified = true; user.ecosystemUserId = "ecoUserId"; user.ecosystemAnonId = "ecoAnonId"; user.scopedKeys = { ...MOCK_ACCOUNT_KEYS.scopedKeys, [DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY]: MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC], [DEPRECATED_SCOPE_WEBEXT_SYNC]: MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC], [EXTRA_SCOPE]: MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC], }; await fxa.setSignedInUser(user); await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC); let newUser = await fxa._internal.getUserAccountData(); // It should have removed the deprecated ecosystem_telemetry key, // and the old kinto extension sync key // but left the other keys intact. const expectedScopedKeys = { ...MOCK_ACCOUNT_KEYS.scopedKeys, [EXTRA_SCOPE]: MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC], }; Assert.deepEqual(newUser.scopedKeys, expectedScopedKeys); Assert.equal(newUser.ecosystemUserId, null); Assert.equal(newUser.ecosystemAnonId, 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); let user = await fxa._internal.getUserAccountData(); Assert.notEqual(user.encryptedSendTabKeys, null); 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); } user = await fxa._internal.getUserAccountData(); Assert.equal(user.email, yusuf.email); Assert.equal(user.keyFetchToken, null); // We verify that encryptedSendTabKeys are also wiped // when a user's credentials are wiped Assert.equal(user.encryptedSendTabKeys, null); await fxa._internal.abortExistingFlow(); }); // Test vectors from // https://wiki.mozilla.org/Identity/AttachedServices/KeyServerProtocol#Test_Vectors 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, }, }); // We mock the server returning the wrapKB from our test vectors client.accountKeys = async () => { return { wrapKB: CommonUtils.hexToBytes( "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f" ), }; }; // We set the user to have the keyFetchToken and unwrapBKey from our test vectors let user = { ...getTestUser("eusebius"), uid: "aeaa1725c7a24ff983c6295725d5fc9b", keyFetchToken: "808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f", unwrapBKey: "6ea660be9c89ec355397f89afb282ea0bf21095760c8c5009bbcc894155bbe2a", sessionToken: "mock session token, used in metadata request", verified: true, }; await fxa.setSignedInUser(user); // We derive, persist and return the sync key const key = await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC); // We verify the key returned matches what we would expect from the test vectors // kb = 2ee722fdd8ccaa721bdeb2d1b76560efef705b04349d9357c3e592cf4906e075 (from test vectors) // // kid can be verified by "${keyRotationTimestamp}-${sha256(kb)[0:16]}" // // k can be verified by HKDF(kb, undefined, "identity.mozilla.com/picl/v1/oldsync", 64) Assert.deepEqual(key, { scope: SCOPE_OLD_SYNC, kid: "1510726317123-BAik7hEOdpGnPZnPBSdaTg", k: "fwM5VZu0Spf5XcFRZYX2zk6MrqZP7zvovCBcvuKwgYMif3hz98FHmIVa3qjKjrW0J244Zj-P5oWaOcQbvypmpw", kty: "oct", }); }); add_task(async function test_getScopedKeys_cached_key() { let fxa = new MockFxAccounts(); let user = { ...getTestUser("eusebius"), uid: "aeaa1725c7a24ff983c6295725d5fc9b", verified: true, ...MOCK_ACCOUNT_KEYS, }; await fxa.setSignedInUser(user); let key = await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC); Assert.deepEqual(key, { scope: SCOPE_OLD_SYNC, ...MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC], }); }); 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, keyFetchToken: "808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f", unwrapBKey: "6ea660be9c89ec355397f89afb282ea0bf21095760c8c5009bbcc894155bbe2a", sessionToken: "mock session token, used in metadata request", }; await fxa.setSignedInUser(user); await Assert.rejects( fxa.keys.getKeyForScope(SCOPE_OLD_SYNC), /The FxA server did not grant Firefox the `oldsync` scope/ ); }); add_task(async function test_setScopedKeys() { const fxa = new MockFxAccounts(); const user = { ...getTestUser("foo"), verified: true, }; await fxa.setSignedInUser(user); await fxa.keys.setScopedKeys(MOCK_ACCOUNT_KEYS.scopedKeys); const key = await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC); Assert.deepEqual(key, { scope: SCOPE_OLD_SYNC, ...MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC], }); }); add_task(async function test_setScopedKeys_user_not_signed_in() { const fxa = new MockFxAccounts(); await Assert.rejects( fxa.keys.setScopedKeys(MOCK_ACCOUNT_KEYS.scopedKeys), /Cannot persist keys, no user signed in/ ); }); // _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_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.setStringPref( "identity.fxaccounts.remote.oauth.uri", "https://example.com/v1" ); 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_getOAuthTokenScopedWithSessionToken() { let fxa = new MockFxAccounts(); let alice = getTestUser("alice"); alice.verified = true; let numOauthTokenCalls = 0; let client = fxa._internal.fxAccountsClient; client.accessTokenWithSessionToken = async ( _sessionTokenHex, _clientId, scopeString ) => { equal(scopeString, "bar foo"); // scopes are sorted locally before request. numOauthTokenCalls++; return MOCK_TOKEN_RESPONSE; }; fxa.setSignedInUser(alice).then(() => { fxa.getOAuthToken({ scope: ["foo", "bar"] }).then(result => { Assert.equal(numOauthTokenCalls, 1); Assert.equal( result, "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69" ); run_next_test(); }); }); }); add_task(async function test_getOAuthTokenCachedScopeNormalization() { let fxa = new MockFxAccounts(); let alice = getTestUser("alice"); alice.verified = true; let numOAuthTokenCalls = 0; let client = fxa._internal.fxAccountsClient; client.accessTokenWithSessionToken = async ( _sessionTokenHex, _clientId, scopeString ) => { numOAuthTokenCalls++; return MOCK_TOKEN_RESPONSE; }; await fxa.setSignedInUser(alice); let result = await fxa.getOAuthToken({ scope: ["foo", "bar"], service: "test-service", }); Assert.equal(numOAuthTokenCalls, 1); Assert.equal( result, "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69" ); // requesting it again with the scope array in a different order should not re-fetch the token. result = await fxa.getOAuthToken({ scope: ["bar", "foo"], service: "test-service", }); Assert.equal(numOAuthTokenCalls, 1); Assert.equal( result, "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69" ); // requesting it again with the scope array in different case should not re-fetch the token. result = await fxa.getOAuthToken({ scope: ["Bar", "Foo"], service: "test-service", }); Assert.equal(numOAuthTokenCalls, 1); Assert.equal( result, "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69" ); // 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, "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69" ); }); 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.getStringPref( "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.setStringPref( "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; let client = fxa._internal.fxAccountsClient; client.accessTokenWithSessionToken = () => { return Promise.reject("boom"); }; fxa.setSignedInUser(alice).then(() => { fxa.getOAuthToken({ scope: "profile" }).catch(err => { equal(err.details, "boom"); run_next_test(); }); }); }); add_task(async function test_listAttachedOAuthClients() { const ONE_HOUR = 60 * 60 * 1000; const ONE_DAY = 24 * ONE_HOUR; const timestamp = Date.now(); let fxa = new MockFxAccounts(); let alice = getTestUser("alice"); alice.verified = true; let client = fxa._internal.fxAccountsClient; client.attachedClients = async () => { return { body: [ // 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", }, ], headers: { "x-timestamp": timestamp.toString() }, }; }; 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_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, encryptedSendTabKeys: name + "'s encrypted Send tab keys", }; } 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; }