/* Any copyright is dedicated to the Public Domain. * http://creativecommons.org/publicdomain/zero/1.0/ */ "use strict"; const { FxAccounts, ERROR_INVALID_ACCOUNT_STATE } = ChromeUtils.importESModule( "resource://gre/modules/FxAccounts.sys.mjs" ); const { FxAccountsClient } = ChromeUtils.importESModule( "resource://gre/modules/FxAccountsClient.sys.mjs" ); const { CLIENT_IS_THUNDERBIRD, ERRNO_INVALID_AUTH_TOKEN, ERROR_NO_ACCOUNT, OAUTH_CLIENT_ID, ONLOGOUT_NOTIFICATION, DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY, } = ChromeUtils.importESModule( "resource://gre/modules/FxAccountsCommon.sys.mjs" ); // We grab some additional stuff via the system global. var { AccountState } = ChromeUtils.importESModule( "resource://gre/modules/FxAccounts.sys.mjs" ); const MOCK_TOKEN_RESPONSE = { access_token: "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69", token_type: "bearer", scope: SCOPE_APP_SYNC, 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.sys.mjs and jwcrypto.sys.mjs. 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 () { // 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 () { 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, OAUTH_CLIENT_ID); Assert.equal(scopes, SCOPE_APP_SYNC); return new Promise(resolve => { do_timeout(50, () => { resolve({ [SCOPE_APP_SYNC]: { identifier: SCOPE_APP_SYNC, 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() { 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.sys.mjs :) 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(); }); }); 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_APP_SYNC).then(() => { fxa._internal.getUserAccountData().then(user3 => { // Now we should have keys 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_oauth_verification() { let fxa = new MockFxAccounts(); let user = getTestUser("eusebius"); user.verified = false; await fxa.setSignedInUser(user); let fetched = await fxa.getSignedInUser(); Assert.ok(!fetched.verified); fxa._withCurrentAccountState(state => { state.updateUserAccountData({ scopedKeys: { test: { foo: "bar" } } }); }); fetched = await fxa.getSignedInUser(); Assert.ok(!fetched.verified); // Simulate the follow-up login message that marks the account verified. await fxa._internal.updateUserAccountData({ uid: user.uid, verified: true, }); fetched = await fxa.getSignedInUser(); Assert.ok(fetched.verified); }); // Tests for hasKeysForScope - checking if sync keys exist locally add_task(async function test_hasKeysForScope_not_signed_in() { const fxa = await MakeFxAccounts(); // Should return false when no user is signed in Assert.ok(!(await fxa.keys.hasKeysForScope(SCOPE_APP_SYNC))); }); add_task(async function test_hasKeysForScope_not_verified() { const credentials = { email: "foo@example.com", uid: "1234567890abcdef1234567890abcdef", sessionToken: "dead", verified: false, // Not verified ...MOCK_ACCOUNT_KEYS, }; const fxa = await MakeFxAccounts({ credentials }); // Should return true even keys exist locally // (Keys are provided during OAuth before the account is marked verified) Assert.ok(await fxa.keys.hasKeysForScope(SCOPE_APP_SYNC)); }); add_task(async function test_hasKeysForScope_no_keys() { const credentials = { email: "foo@example.com", uid: "1234567890abcdef1234567890abcdef", sessionToken: "dead", verified: true, // NO scopedKeys - third party auth scenario }; const fxa = await MakeFxAccounts({ credentials }); // Should return false when user has no sync keys (third-party auth) Assert.ok(!(await fxa.keys.hasKeysForScope(SCOPE_APP_SYNC))); }); add_task(async function test_hasKeysForScope_with_keys() { const credentials = { email: "foo@example.com", uid: "1234567890abcdef1234567890abcdef", sessionToken: "dead", verified: true, ...MOCK_ACCOUNT_KEYS, // Has sync keys }; const fxa = await MakeFxAccounts({ credentials }); // Should return true when user has sync keys Assert.ok(await fxa.keys.hasKeysForScope(SCOPE_APP_SYNC)); }); add_task(async function test_hasKeysForScope_wrong_scope() { const credentials = { email: "foo@example.com", uid: "1234567890abcdef1234567890abcdef", sessionToken: "dead", verified: true, ...MOCK_ACCOUNT_KEYS, }; const fxa = await MakeFxAccounts({ credentials }); // Should return false for a scope we don't have keys for Assert.ok( !(await fxa.keys.hasKeysForScope( "https://identity.mozilla.com/apps/unknown" )) ); }); 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_APP_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_APP_SYNC], [DEPRECATED_SCOPE_WEBEXT_SYNC]: MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_APP_SYNC], [EXTRA_SCOPE]: MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_APP_SYNC], }; await fxa.setSignedInUser(user); await fxa.keys.getKeyForScope(SCOPE_APP_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_APP_SYNC], }; Assert.deepEqual(newUser.scopedKeys, expectedScopedKeys); Assert.equal(newUser.ecosystemUserId, null); Assert.equal(newUser.ecosystemAnonId, null); } ); // 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({ [SCOPE_APP_SYNC]: { identifier: SCOPE_APP_SYNC, 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_APP_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_APP_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_APP_SYNC); Assert.deepEqual(key, { scope: SCOPE_APP_SYNC, ...MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_APP_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_APP_SYNC), /The FxA server did not grant Firefox the sync 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_APP_SYNC); Assert.deepEqual(key, { scope: SCOPE_APP_SYNC, ...MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_APP_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(() => { fxa._internal.abortExistingFlow().then(run_next_test); }); }); fxa .setSignedInUser(user) .then(() => { return fxa.keys._fetchAndUnwrapAndDeriveKeys(); }) .catch(() => { log.info("setSignedInUser correctly rejected"); }); }); 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"); // 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 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"); 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, CLIENT_IS_THUNDERBIRD ? "8269bacd7bbc7f80" : "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) => { 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_test(async function test_getOAuthTokenAndKey_errors_if_user_change() { const fxa = new MockFxAccounts(); const alice = getTestUser("alice"); const bob = getTestUser("bob"); alice.verified = true; bob.verified = true; fxa.getOAuthToken = async () => { // We mock what would happen if the user got changed // after we got the access token await fxa.setSignedInUser(bob); return "access token"; }; fxa.keys.getKeyForScope = () => Promise.resolve("key!"); await fxa.setSignedInUser(alice); await Assert.rejects( fxa.getOAuthTokenAndKey({ scope: "foo", ttl: 10 }), err => { Assert.equal(err.message, ERROR_INVALID_ACCOUNT_STATE); return true; // expected error } ); 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", SCOPE_APP_SYNC], 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_listAttachedOAuthClients_withCaching() { 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; let originalResponse = { body: [ { clientId: "a2270f727f45f648", deviceId: "deadbeef", sessionTokenId: null, name: "Firefox Preview (no session token)", scope: ["profile", SCOPE_APP_SYNC], lastAccessTime: Date.now(), }, { clientId: "802d56ef2a9af9fa", deviceId: null, sessionTokenId: null, name: "Firefox Monitor", scope: ["profile"], lastAccessTime: Date.now() - ONE_DAY - ONE_HOUR, }, ], headers: { "x-timestamp": timestamp.toString() }, }; // Mock the client method. client.attachedClients = async () => { return originalResponse; }; await fxa.setSignedInUser(alice); // First call: should fetch from server const clientsFirstCall = await fxa.listAttachedOAuthClients(); Assert.deepEqual(clientsFirstCall, [ { id: "a2270f727f45f648", lastAccessedDaysAgo: 0 }, { id: "802d56ef2a9af9fa", lastAccessedDaysAgo: 1 }, ]); // Now modify the client so if it calls again, we would get different clients. const updatedResponse = { body: [ { clientId: "updated-client", lastAccessTime: Date.now() - ONE_DAY * 3, }, ], headers: { "x-timestamp": (timestamp + 1000).toString() }, }; client.attachedClients = async () => { return updatedResponse; }; // Second call without forceRefresh: should return cached data, not the updated one. const clientsSecondCall = await fxa.listAttachedOAuthClients(); Assert.deepEqual( clientsSecondCall, [ { id: "a2270f727f45f648", lastAccessedDaysAgo: 0 }, { id: "802d56ef2a9af9fa", lastAccessedDaysAgo: 1 }, ], "Should return cached clients from the first call, ignoring the updated mock" ); // Now force refresh const clientsForceRefresh = await fxa.listAttachedOAuthClients(true); Assert.deepEqual( clientsForceRefresh, [{ id: "updated-client", lastAccessedDaysAgo: 3 }], "Forcing a refresh should return the updated data" ); }); 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_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; }