Bug 1855264: Prevents access token and scoped key race condition. r=markh,sync-reviewers

Differential Revision: https://phabricator.services.mozilla.com/D199643
This commit is contained in:
Tarik Eshaq 2024-02-22 16:48:45 +00:00
parent 6cb899e761
commit 4c7403d5ba
4 changed files with 105 additions and 45 deletions

View file

@ -65,6 +65,8 @@ XPCOMUtils.defineLazyPreferenceGetter(
true true
); );
export const ERROR_INVALID_ACCOUNT_STATE = "ERROR_INVALID_ACCOUNT_STATE";
// An AccountState object holds all state related to one specific account. // An AccountState object holds all state related to one specific account.
// It is considered "private" to the FxAccounts modules. // It is considered "private" to the FxAccounts modules.
// Only one AccountState is ever "current" in the FxAccountsInternal object - // Only one AccountState is ever "current" in the FxAccountsInternal object -
@ -170,7 +172,7 @@ AccountState.prototype = {
delete updatedFields.uid; delete updatedFields.uid;
} }
if (!this.isCurrent) { if (!this.isCurrent) {
return Promise.reject(new Error("Another user has signed in")); return Promise.reject(new Error(ERROR_INVALID_ACCOUNT_STATE));
} }
return this.storageManager.updateAccountData(updatedFields); return this.storageManager.updateAccountData(updatedFields);
}, },
@ -179,11 +181,11 @@ AccountState.prototype = {
if (!this.isCurrent) { if (!this.isCurrent) {
log.info( log.info(
"An accountState promise was resolved, but was actually rejected" + "An accountState promise was resolved, but was actually rejected" +
" due to a different user being signed in. Originally resolved" + " due to the account state changing. This can happen if a new account signed in, or" +
" with", " the account was signed out. Originally resolved with, ",
result result
); );
return Promise.reject(new Error("A different user signed in")); return Promise.reject(new Error(ERROR_INVALID_ACCOUNT_STATE));
} }
return Promise.resolve(result); return Promise.resolve(result);
}, },
@ -195,12 +197,13 @@ AccountState.prototype = {
// problems. // problems.
if (!this.isCurrent) { if (!this.isCurrent) {
log.info( log.info(
"An accountState promise was rejected, but we are ignoring that " + "An accountState promise was rejected, but we are ignoring that" +
"reason and rejecting it due to a different user being signed in. " + " reason and rejecting it due to the account state changing. This can happen if" +
"Originally rejected with", " a different account signed in or the account was signed out" +
" originally resolved with, ",
error error
); );
return Promise.reject(new Error("A different user signed in")); return Promise.reject(new Error(ERROR_INVALID_ACCOUNT_STATE));
} }
return Promise.reject(error); return Promise.reject(error);
}, },
@ -215,7 +218,7 @@ AccountState.prototype = {
// A preamble for the cache helpers... // A preamble for the cache helpers...
_cachePreamble() { _cachePreamble() {
if (!this.isCurrent) { if (!this.isCurrent) {
throw new Error("Another user has signed in"); throw new Error(ERROR_INVALID_ACCOUNT_STATE);
} }
}, },
@ -466,6 +469,37 @@ export class FxAccounts {
} }
} }
/** Gets both the OAuth token and the users scoped keys for that token
* and verifies that both operations were done for the same user,
* preventing race conditions where a caller
* can get the key for one user, and the id of another if the user
* is rapidly switching between accounts
*
* @param options
* {
* scope: string the oauth scope being requested. This must
* be a scope with an associated key, otherwise an error
* will be thrown that the key is not available.
* ttl: (number) OAuth token TTL in seconds
* }
*
* @return Promise.<Object | Error>
* The promise resolve to both the access token being requested, and the scoped key
* {
* token: (string) access token
* key: (object) the scoped key object
* }
* The promise can reject, with one of the errors `getOAuthToken`, `FxAccountKeys.getKeyForScope`, or
* error if the user changed in-between operations
*/
getOAuthTokenAndKey(options = {}) {
return this._withCurrentAccountState(async () => {
const key = await this.keys.getKeyForScope(options.scope);
const token = await this.getOAuthToken(options);
return { token, key };
});
}
/** /**
* Remove an OAuth token from the token cache. Callers should call this * Remove an OAuth token from the token cache. Callers should call this
* after they determine a token is invalid, so a new token will be fetched * after they determine a token is invalid, so a new token will be fetched

View file

@ -6,7 +6,7 @@
const { CryptoUtils } = ChromeUtils.importESModule( const { CryptoUtils } = ChromeUtils.importESModule(
"resource://services-crypto/utils.sys.mjs" "resource://services-crypto/utils.sys.mjs"
); );
const { FxAccounts } = ChromeUtils.importESModule( const { FxAccounts, ERROR_INVALID_ACCOUNT_STATE } = ChromeUtils.importESModule(
"resource://gre/modules/FxAccounts.sys.mjs" "resource://gre/modules/FxAccounts.sys.mjs"
); );
const { FxAccountsClient } = ChromeUtils.importESModule( const { FxAccountsClient } = ChromeUtils.importESModule(
@ -777,11 +777,10 @@ add_task(async function test_getKeyForScope_nonexistent_account() {
}); });
}); });
// XXX - the exception message here isn't ideal, but doesn't really matter... await Assert.rejects(fxa.keys.getKeyForScope(SCOPE_OLD_SYNC), err => {
await Assert.rejects( Assert.equal(err.message, ERROR_INVALID_ACCOUNT_STATE);
fxa.keys.getKeyForScope(SCOPE_OLD_SYNC), return true; // expected error
/A different user signed in/ });
);
await promiseLogout; await promiseLogout;
@ -1405,6 +1404,31 @@ add_test(function test_getOAuthToken_error() {
}); });
}); });
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() { add_task(async function test_listAttachedOAuthClients() {
const ONE_HOUR = 60 * 60 * 1000; const ONE_HOUR = 60 * 60 * 1000;
const ONE_DAY = 24 * ONE_HOUR; const ONE_DAY = 24 * ONE_HOUR;

View file

@ -387,22 +387,28 @@ SyncAuthManager.prototype = {
// Do the token dance, with a retry in case of transient auth failure. // Do the token dance, with a retry in case of transient auth failure.
// We need to prove that we know the sync key in order to get a token // We need to prove that we know the sync key in order to get a token
// from the tokenserver. // from the tokenserver.
let getToken = async key => { let getToken = async (key, accessToken) => {
this._log.info("Getting a sync token from", this._tokenServerUrl); this._log.info("Getting a sync token from", this._tokenServerUrl);
let token = await this._fetchTokenUsingOAuth(key); let token = await this._fetchTokenUsingOAuth(key, accessToken);
this._log.trace("Successfully got a token"); this._log.trace("Successfully got a token");
return token; return token;
}; };
const ttl = fxAccountsCommon.OAUTH_TOKEN_FOR_SYNC_LIFETIME_SECONDS;
try { try {
let token, key; let token, key;
try { try {
this._log.info("Getting sync key"); this._log.info("Getting sync key");
key = await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC); const tokenAndKey = await fxa.getOAuthTokenAndKey({
scope: SCOPE_OLD_SYNC,
ttl,
});
key = tokenAndKey.key;
if (!key) { if (!key) {
throw new Error("browser does not have the sync key, cannot sync"); throw new Error("browser does not have the sync key, cannot sync");
} }
token = await getToken(key); token = await getToken(key, tokenAndKey.token);
} catch (err) { } catch (err) {
// If we get a 401 fetching the token it may be that our auth tokens needed // If we get a 401 fetching the token it may be that our auth tokens needed
// to be regenerated; retry exactly once. // to be regenerated; retry exactly once.
@ -412,8 +418,11 @@ SyncAuthManager.prototype = {
this._log.warn( this._log.warn(
"Token server returned 401, retrying token fetch with fresh credentials" "Token server returned 401, retrying token fetch with fresh credentials"
); );
key = await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC); const tokenAndKey = await fxa.getOAuthTokenAndKey({
token = await getToken(key); scope: SCOPE_OLD_SYNC,
ttl,
});
token = await getToken(tokenAndKey.key, tokenAndKey.token);
} }
// TODO: Make it be only 80% of the duration, so refresh the token // TODO: Make it be only 80% of the duration, so refresh the token
// before it actually expires. This is to avoid sync storage errors // before it actually expires. This is to avoid sync storage errors
@ -460,17 +469,13 @@ SyncAuthManager.prototype = {
}, },
/** /**
* Generates an OAuth access_token using the OLD_SYNC scope and exchanges it * Exchanges an OAuth access_token for a TokenServer token.
* for a TokenServer token.
*
* @returns {Promise} * @returns {Promise}
* @private * @private
*/ */
async _fetchTokenUsingOAuth(key) { async _fetchTokenUsingOAuth(key, accessToken) {
this._log.debug("Getting a token using OAuth"); this._log.debug("Getting a token using OAuth");
const fxa = this._fxaService; const fxa = this._fxaService;
const ttl = fxAccountsCommon.OAUTH_TOKEN_FOR_SYNC_LIFETIME_SECONDS;
const accessToken = await fxa.getOAuthToken({ scope: SCOPE_OLD_SYNC, ttl });
const headers = { const headers = {
"X-KeyId": key.kid, "X-KeyId": key.kid,
}; };

View file

@ -37,9 +37,8 @@ const { TokenServerClient, TokenServerClientServerError } =
ChromeUtils.importESModule( ChromeUtils.importESModule(
"resource://services-common/tokenserverclient.sys.mjs" "resource://services-common/tokenserverclient.sys.mjs"
); );
const { AccountState } = ChromeUtils.importESModule( const { AccountState, ERROR_INVALID_ACCOUNT_STATE } =
"resource://gre/modules/FxAccounts.sys.mjs" ChromeUtils.importESModule("resource://gre/modules/FxAccounts.sys.mjs");
);
const SECOND_MS = 1000; const SECOND_MS = 1000;
const MINUTE_MS = SECOND_MS * 60; const MINUTE_MS = SECOND_MS * 60;
@ -192,8 +191,11 @@ add_task(async function test_initialializeWithAuthErrorAndDeletedAccount() {
await Assert.rejects( await Assert.rejects(
syncAuthManager._ensureValidToken(), syncAuthManager._ensureValidToken(),
AuthenticationError, err => {
"should reject due to an auth error" Assert.equal(err.message, ERROR_INVALID_ACCOUNT_STATE);
return true; // expected error
},
"should reject because the account was deleted"
); );
Assert.ok(accessTokenWithSessionTokenCalled); Assert.ok(accessTokenWithSessionTokenCalled);
@ -801,14 +803,11 @@ add_task(async function test_getKeysMissing() {
storageManager.initialize(identityConfig.fxaccount.user); storageManager.initialize(identityConfig.fxaccount.user);
return new AccountState(storageManager); return new AccountState(storageManager);
}, },
// And the keys object with a mock that returns no keys.
keys: {
getKeyForScope() {
return Promise.resolve(null);
},
},
}); });
fxa.getOAuthTokenAndKey = () => {
// And the keys object with a mock that returns no keys.
return Promise.resolve({ key: null, token: "fake token" });
};
syncAuthManager._fxaService = fxa; syncAuthManager._fxaService = fxa;
await Assert.rejects( await Assert.rejects(
@ -844,14 +843,12 @@ add_task(async function test_getKeysUnexpecedError() {
storageManager.initialize(identityConfig.fxaccount.user); storageManager.initialize(identityConfig.fxaccount.user);
return new AccountState(storageManager); return new AccountState(storageManager);
}, },
// And the keys object with a mock that returns no keys.
keys: {
async getKeyForScope() {
throw new Error("well that was unexpected");
},
},
}); });
fxa.getOAuthTokenAndKey = () => {
return Promise.reject("well that was unexpected");
};
syncAuthManager._fxaService = fxa; syncAuthManager._fxaService = fxa;
await Assert.rejects( await Assert.rejects(