Bug 1139743 (part 3) - Cache OAuth tokens. r=ckarlof

This commit is contained in:
Mark Hammond 2015-04-03 12:47:00 +11:00
parent 778766acf8
commit f6ba77b103
11 changed files with 987 additions and 63 deletions

View File

@ -68,11 +68,6 @@ ServerClient.prototype = {
},
_removeToken(token) {
// XXX - remove this check once tokencaching landsin FxA.
if (!this.fxa.removeCachedOAuthToken) {
dump("XXX - token caching support is yet to land - can't remove token!");
return;
}
return this.fxa.removeCachedOAuthToken({token});
},

View File

@ -45,6 +45,7 @@ let publicProperties = [
"now",
"promiseAccountsForceSigninURI",
"promiseAccountsChangeProfileURI",
"removeCachedOAuthToken",
"resendVerificationEmail",
"setSignedInUser",
"signOut",
@ -70,18 +71,24 @@ let publicProperties = [
// }
// If the state has changed between the function being called and the promise
// being resolved, the .resolve() call will actually be rejected.
let AccountState = function(fxaInternal) {
let AccountState = function(fxaInternal, signedInUserStorage, accountData = null) {
this.fxaInternal = fxaInternal;
this.signedInUserStorage = signedInUserStorage;
this.signedInUser = accountData ? {version: DATA_FORMAT_VERSION, accountData} : null;
this.uid = accountData ? accountData.uid : null;
this.oauthTokens = {};
};
AccountState.prototype = {
cert: null,
keyPair: null,
signedInUser: null,
oauthTokens: null,
whenVerifiedDeferred: null,
whenKeysReadyDeferred: null,
profile: null,
promiseInitialAccountData: null,
uid: null,
get isCurrent() this.fxaInternal && this.fxaInternal.currentAccountState === this,
@ -101,6 +108,7 @@ AccountState.prototype = {
this.cert = null;
this.keyPair = null;
this.signedInUser = null;
this.uid = null;
this.fxaInternal = null;
this.initProfilePromise = null;
@ -110,7 +118,17 @@ AccountState.prototype = {
}
},
getUserAccountData: function() {
// Clobber all cached data and write that empty data to storage.
signOut() {
this.cert = null;
this.keyPair = null;
this.signedInUser = null;
this.oauthTokens = {};
this.uid = null;
return this.persistUserData();
},
getUserAccountData() {
if (!this.isCurrent) {
return this.reject(new Error("Another user has signed in"));
}
@ -123,33 +141,70 @@ AccountState.prototype = {
return this.resolve(this.signedInUser.accountData);
}
// First and only read.
return this.promiseInitialAccountData = this.fxaInternal.signedInUserStorage.get().then(
user => {
// We fetch the signedInUser data first, then fetch the token store and
// ensure the uid in the tokens matches our user.
let accountData = null;
let oauthTokens = {};
return this.promiseInitialAccountData = this.signedInUserStorage.get()
.then(user => {
if (logPII) {
// don't stringify unless it will be written. We should replace this
// check with param substitutions added in bug 966674
log.debug("getUserAccountData -> " + JSON.stringify(user));
log.debug("getUserAccountData", user);
}
if (user && user.version == this.version) {
log.debug("setting signed in user");
this.signedInUser = user;
}
this.promiseInitialAccountData = null;
return this.resolve(user ? user.accountData : null);
},
err => {
// In an ideal world we could cache the data in this.signedInUser, but
// if we do, the interaction with the login manager breaks when the
// password is locked as this read may only have obtained partial data.
// Therefore every read *must* really read incase the login manager is
// now unlocked. We could fix this with a refactor...
accountData = user ? user.accountData : null;
}, err => {
// Error reading signed in user account data.
this.promiseInitialAccountData = null;
if (err instanceof OS.File.Error && err.becauseNoSuchFile) {
// File hasn't been created yet. That will be done
// on the first call to getSignedInUser
return this.resolve(null);
// on the first call to setSignedInUser
return;
}
return this.reject(err);
}
);
// something else went wrong - report the error but continue without
// user data.
log.error("Failed to read signed in user data", err);
}).then(() => {
if (!accountData) {
return null;
}
return this.signedInUserStorage.getOAuthTokens();
}).then(tokenData => {
if (tokenData && tokenData.tokens &&
tokenData.version == DATA_FORMAT_VERSION &&
tokenData.uid == accountData.uid ) {
oauthTokens = tokenData.tokens;
}
}, err => {
// Error reading the OAuth tokens file.
if (err instanceof OS.File.Error && err.becauseNoSuchFile) {
// File hasn't been created yet, but will be when tokens are saved.
return;
}
log.error("Failed to read oauth tokens", err)
}).then(() => {
// We are done - clear our promise and save the data if we are still
// current.
this.promiseInitialAccountData = null;
if (this.isCurrent) {
// As above, we can not cache the data to this.signedInUser as we
// may only have partial data due to a locked MP, so the next
// request must re-read incase it is now unlocked.
// But we do save the tokens and the uid
this.oauthTokens = oauthTokens;
this.uid = accountData ? accountData.uid : null;
}
return accountData;
});
// phew!
},
// XXX - this should really be called "updateCurrentUserData" or similar as
// it is only ever used to add new fields to the *current* user, not to
// set a new user as current.
setUserAccountData: function(accountData) {
if (!this.isCurrent) {
return this.reject(new Error("Another user has signed in"));
@ -157,10 +212,18 @@ AccountState.prototype = {
if (this.promiseInitialAccountData) {
throw new Error("Can't set account data before it's been read.");
}
if (!accountData) {
// see above - this should really be called "updateCurrentUserData" or similar.
throw new Error("Attempt to use setUserAccountData with null user data.");
}
if (accountData.uid != this.uid) {
// see above - this should really be called "updateCurrentUserData" or similar.
throw new Error("Attempt to use setUserAccountData with a different user.");
}
// Set our signedInUser before we start the write, so any updates to the
// data while the write completes are still captured.
this.signedInUser = {version: DATA_FORMAT_VERSION, accountData: accountData};
return this.fxaInternal.signedInUserStorage.set(this.signedInUser)
return this.signedInUserStorage.set(this.signedInUser)
.then(() => this.resolve(accountData));
},
@ -289,7 +352,107 @@ AccountState.prototype = {
return Promise.reject(error);
},
};
// Abstractions for storage of cached tokens - these are all sync, and don't
// handle revocation etc - it's just storage.
// A preamble for the cache helpers...
_cachePreamble() {
if (!this.isCurrent) {
throw new Error("Another user has signed in");
}
},
// Set a cached token. |tokenData| must have a 'token' element, but may also
// have additional fields (eg, it probably specifies the server to revoke
// from). The 'get' functions below return the entire |tokenData| value.
setCachedToken(scopeArray, tokenData) {
this._cachePreamble();
if (!tokenData.token) {
throw new Error("No token");
}
let key = getScopeKey(scopeArray);
this.oauthTokens[key] = tokenData;
// And a background save...
this._persistCachedTokens();
},
// Return data for a cached token or null (or throws on bad state etc)
getCachedToken(scopeArray) {
this._cachePreamble();
let key = getScopeKey(scopeArray);
if (this.oauthTokens[key]) {
// later we might want to check an expiry date - but we currently
// have no such concept, so just return it.
log.trace("getCachedToken returning cached token");
return this.oauthTokens[key];
}
return null;
},
// Get an array of tokenData for all cached tokens.
getAllCachedTokens() {
this._cachePreamble();
let result = [];
for (let [key, tokenValue] in Iterator(this.oauthTokens)) {
result.push(tokenValue);
}
return result;
},
// Remove a cached token from the cache. Does *not* revoke it from anywhere.
// Returns the entire token entry if found, null otherwise.
removeCachedToken(token) {
this._cachePreamble();
let data = this.oauthTokens;
for (let [key, tokenValue] in Iterator(data)) {
if (tokenValue.token == token) {
delete data[key];
// And a background save...
this._persistCachedTokens();
return tokenValue;
}
}
return null;
},
// A hook-point for tests. Returns a promise that's ignored in most cases
// (notable exceptions are tests and when we explicitly are saving the entire
// set of user data.)
_persistCachedTokens() {
this._cachePreamble();
let record;
if (this.uid) {
record = {
version: DATA_FORMAT_VERSION,
uid: this.uid,
tokens: this.oauthTokens,
};
} else {
record = null;
}
return this.signedInUserStorage.setOAuthTokens(record).catch(
err => {
log.error("Failed to save account data for token cache", err);
}
);
},
persistUserData() {
return this._persistCachedTokens().catch(err => {
log.error("Failed to persist cached tokens", err);
}).then(() => {
return this.signedInUserStorage.set(this.signedInUser);
}).catch(err => {
log.error("Failed to persist account data", err);
});
},
}
/* Given an array of scopes, make a string key by normalizing. */
function getScopeKey(scopeArray) {
let normalizedScopes = scopeArray.map(item => item.toLowerCase());
return normalizedScopes.sort().join("|");
}
/**
* Copies properties from a given object to another object.
@ -349,6 +512,11 @@ this.FxAccounts = function (mockInternal) {
}
if (mockInternal) {
// A little work-around to ensure the initial currentAccountState has
// the same mock storage the test passed in.
if (mockInternal.signedInUserStorage) {
internal.currentAccountState.signedInUserStorage = mockInternal.signedInUserStorage;
}
// Exposes the internal object for testing only.
external.internal = internal;
}
@ -365,6 +533,28 @@ function FxAccountsInternal() {
// Make a local copy of this constant so we can mock it in testing
this.POLL_SESSION = POLL_SESSION;
// The one and only "storage" object. While this is created here, the
// FxAccountsInternal object does *not* use it directly, but instead passes
// it to AccountState objects which has sole responsibility for storage.
// Ideally we would create it in the AccountState objects, but that makes
// testing hard as AccountState objects are regularly created and thrown
// away. Doing it this way means tests can mock/replace this storage object
// and have it used by all AccountState objects, even those created before
// and after the mock has been setup.
// We only want the fancy LoginManagerStorage on desktop.
#if defined(MOZ_B2G)
this.signedInUserStorage = new JSONStorage({
#else
this.signedInUserStorage = new LoginManagerStorage({
#endif
// We don't reference |profileDir| in the top-level module scope
// as we may be imported before we know where it is.
filename: DEFAULT_STORAGE_FILENAME,
oauthTokensFilename: DEFAULT_OAUTH_TOKENS_FILENAME,
baseDir: OS.Constants.Path.profileDir,
});
// We interact with the Firefox Accounts auth server in order to confirm that
// a user's email has been verified and also to fetch the user's keys from
// the server. We manage these processes in possibly long-lived promises
@ -376,19 +566,7 @@ function FxAccountsInternal() {
// currentAccountState are used for this purpose.
// (XXX - should the timer be directly on the currentAccountState?)
this.currentTimer = null;
this.currentAccountState = new AccountState(this);
// We don't reference |profileDir| in the top-level module scope
// as we may be imported before we know where it is.
// We only want the fancy new LoginManagerStorage on desktop.
#if defined(MOZ_B2G)
this.signedInUserStorage = new JSONStorage({
#else
this.signedInUserStorage = new LoginManagerStorage({
#endif
filename: DEFAULT_STORAGE_FILENAME,
baseDir: OS.Constants.Path.profileDir,
});
this.currentAccountState = new AccountState(this, this.signedInUserStorage);
}
/**
@ -522,19 +700,22 @@ FxAccountsInternal.prototype = {
log.debug("setSignedInUser - aborting any existing flows");
this.abortExistingFlow();
let record = {version: this.version, accountData: credentials};
let currentState = this.currentAccountState;
// Cache a clone of the credentials object.
currentState.signedInUser = JSON.parse(JSON.stringify(record));
let currentAccountState = this.currentAccountState = new AccountState(
this,
this.signedInUserStorage,
JSON.parse(JSON.stringify(credentials)) // Pass a clone of the credentials object.
);
// This promise waits for storage, but not for verification.
// We're telling the caller that this is durable now.
return this.signedInUserStorage.set(record).then(() => {
return currentAccountState.persistUserData().then(() => {
this.notifyObservers(ONLOGIN_NOTIFICATION);
if (!this.isUserEmailVerified(credentials)) {
this.startVerifiedCheck(credentials);
}
}).then(result => currentState.resolve(result));
}).then(() => {
return currentAccountState.resolve();
});
},
/**
@ -591,7 +772,7 @@ FxAccountsInternal.prototype = {
this.currentTimer = 0;
}
this.currentAccountState.abort();
this.currentAccountState = new AccountState(this);
this.currentAccountState = new AccountState(this, this.signedInUserStorage);
},
accountStatus: function accountStatus() {
@ -603,12 +784,31 @@ FxAccountsInternal.prototype = {
});
},
_destroyOAuthToken: function(tokenData) {
let client = new FxAccountsOAuthGrantClient({
serverURL: tokenData.server,
client_id: FX_OAUTH_CLIENT_ID
});
return client.destroyToken(tokenData.token)
},
_destroyAllOAuthTokens: function(tokenInfos) {
// let's just destroy them all in parallel...
let promises = [];
for (let tokenInfo of tokenInfos) {
promises.push(this._destroyOAuthToken(tokenInfo));
}
return Promise.all(promises);
},
signOut: function signOut(localOnly) {
let currentState = this.currentAccountState;
let sessionToken;
let tokensToRevoke;
return currentState.getUserAccountData().then(data => {
// Save the session token for use in the call to signOut below.
sessionToken = data && data.sessionToken;
tokensToRevoke = currentState.getAllCachedTokens();
return this._signOutLocal();
}).then(() => {
// FxAccountsManager calls here, then does its own call
@ -621,8 +821,15 @@ FxAccountsInternal.prototype = {
// the user from signing out. The server must tolerate
// clients just disappearing, so this call should be best effort.
return this._signOutServer(sessionToken);
}).then(null, err => {
log.error("Error during remote sign out of Firefox Accounts: " + err);
}).catch(err => {
log.error("Error during remote sign out of Firefox Accounts", err);
}).then(() => {
return this._destroyAllOAuthTokens(tokensToRevoke);
}).catch(err => {
log.error("Error during destruction of oauth tokens during signout", err);
}).then(() => {
// just for testing - notifications are cheap when no observers.
this.notifyObservers("testhelper-fxa-signout-complete");
});
}
}).then(() => {
@ -635,9 +842,10 @@ FxAccountsInternal.prototype = {
* signOut via FxAccountsClient.
*/
_signOutLocal: function signOutLocal() {
this.abortExistingFlow();
this.currentAccountState.signedInUser = null; // clear in-memory cache
return this.signedInUserStorage.set(null);
let currentAccountState = this.currentAccountState;
return currentAccountState.signOut().then(() => {
this.abortExistingFlow(); // this resets this.currentAccountState.
});
},
_signOutServer: function signOutServer(sessionToken) {
@ -1008,7 +1216,9 @@ FxAccountsInternal.prototype = {
*
* @param options
* {
* scope: (string) the oauth scope being requested
* scope: (string/array) the oauth scope(s) being requested. As a
* convenience, you may pass a string if only one scope is
* required, or an array of strings if multiple are needed.
* }
*
* @return Promise.<string | Error>
@ -1023,35 +1233,94 @@ FxAccountsInternal.prototype = {
*/
getOAuthToken: Task.async(function* (options = {}) {
log.debug("getOAuthToken enter");
if (!options.scope) {
throw this._error(ERROR_INVALID_PARAMETER, "Missing 'scope' option");
let scope = options.scope;
if (typeof scope === "string") {
scope = [scope];
}
let oAuthURL = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.oauth.uri");
if (!scope || !scope.length) {
throw this._error(ERROR_INVALID_PARAMETER, "Missing or invalid 'scope' option");
}
yield this._getVerifiedAccountOrReject();
// Early exit for a cached token.
let currentState = this.currentAccountState;
let cached = currentState.getCachedToken(scope);
if (cached) {
log.debug("getOAuthToken returning a cached token");
return cached.token;
}
// We are going to hit the server - this is the string we pass to it.
let scopeString = scope.join(" ");
let client = options.client;
if (!client) {
try {
let defaultURL = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.oauth.uri");
client = new FxAccountsOAuthGrantClient({
serverURL: oAuthURL,
serverURL: defaultURL,
client_id: FX_OAUTH_CLIENT_ID
});
} catch (e) {
throw this._error(ERROR_INVALID_PARAMETER, e);
}
}
let oAuthURL = client.serverURL.href;
try {
yield this._getVerifiedAccountOrReject();
log.debug("getOAuthToken fetching new token from", oAuthURL);
let assertion = yield this.getAssertion(oAuthURL);
let result = yield client.getTokenFromAssertion(assertion, options.scope);
return result.access_token;
let result = yield client.getTokenFromAssertion(assertion, scopeString);
let token = result.access_token;
// If we got one, cache it.
if (token) {
let entry = {token: token, server: oAuthURL};
// But before we do, check the cache again - if we find one now, it
// means someone else concurrently requested the same scope and beat
// us to the cache write. To be nice to the server, we revoke the one
// we just got and return the newly cached value.
let cached = currentState.getCachedToken(scope);
if (cached) {
log.debug("Detected a race for this token - revoking the new one.");
this._destroyOAuthToken(entry);
return cached.token;
}
currentState.setCachedToken(scope, entry);
}
return token;
} catch (err) {
throw this._errorToErrorClass(err);
}
}),
/**
* 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
* on the next call to getOAuthToken().
*
* @param options
* {
* token: (string) A previously fetched token.
* }
* @return Promise.<undefined> This function will always resolve, even if
* an unknown token is passed.
*/
removeCachedOAuthToken: Task.async(function* (options) {
if (!options.token || typeof options.token !== "string") {
throw this._error(ERROR_INVALID_PARAMETER, "Missing or invalid 'token' option");
}
let currentState = this.currentAccountState;
let existing = currentState.removeCachedToken(options.token);
if (existing) {
// background destroy.
this._destroyOAuthToken(existing).catch(err => {
log.warn("FxA failed to revoke a cached token", err);
});
}
}),
_getVerifiedAccountOrReject: Task.async(function* () {
let data = yield this.currentAccountState.getUserAccountData();
if (!data) {
@ -1148,6 +1417,7 @@ FxAccountsInternal.prototype = {
function JSONStorage(options) {
this.baseDir = options.baseDir;
this.path = OS.Path.join(options.baseDir, options.filename);
this.oauthTokensPath = OS.Path.join(options.baseDir, options.oauthTokensFilename);
};
JSONStorage.prototype = {
@ -1158,7 +1428,17 @@ JSONStorage.prototype = {
get: function() {
return CommonUtils.readJSON(this.path);
}
},
setOAuthTokens: function(contents) {
return OS.File.makeDir(this.baseDir, {ignoreExisting: true})
.then(CommonUtils.writeJSON.bind(null, contents, this.oauthTokensPath));
},
getOAuthTokens: function(contents) {
return CommonUtils.readJSON(this.oauthTokensPath);
},
};
/**
@ -1347,6 +1627,16 @@ LoginManagerStorage.prototype = {
return data;
}),
// OAuth tokens are always written to disk, so delegate to our JSON storage.
// (Bug 1013064 comments 23-25 explain why we save the sessionToken into the
// plain JSON file, and the same logic applies for oauthTokens being in JSON)
getOAuthTokens() {
return this.jsonStorage.getOAuthTokens();
},
setOAuthTokens(contents) {
return this.jsonStorage.setOAuthTokens(contents);
},
}
// A getter for the instance to export

View File

@ -66,6 +66,7 @@ exports.FXACCOUNTS_PERMISSION = "firefox-accounts";
exports.DATA_FORMAT_VERSION = 1;
exports.DEFAULT_STORAGE_FILENAME = "signedInUser.json";
exports.DEFAULT_OAUTH_TOKENS_FILENAME = "signedInUserOAuthTokens.json";
// Token life times.
// Having this parameter be short has limited security value and can cause

View File

@ -20,6 +20,7 @@ Cu.import("resource://services-common/rest.js");
Cu.importGlobalProperties(["URL"]);
const AUTH_ENDPOINT = "/authorization";
const DESTROY_ENDPOINT = "/destroy";
/**
* Create a new FxAccountsOAuthClient for browser some service.
@ -75,6 +76,25 @@ this.FxAccountsOAuthGrantClient.prototype = {
return this._createRequest(AUTH_ENDPOINT, "POST", params);
},
/**
* Destroys a previously fetched OAuth access token.
*
* @param {String} token The previously fetched token
* @return Promise
* Resolves: {Object} with the server response, which is typically
* ignored.
*/
destroyToken: function (token) {
if (!token) {
throw new Error("Missing 'token' parameter");
}
let params = {
token: token,
};
return this._createRequest(DESTROY_ENDPOINT, "POST", params);
},
/**
* Validates the required FxA OAuth parameters
*

View File

@ -45,6 +45,12 @@ MockStorage.prototype = Object.freeze({
get: function () {
return Promise.resolve(this.data);
},
getOAuthTokens() {
return Promise.resolve(null);
},
setOAuthTokens(contents) {
return Promise.resolve();
},
});
function MockFxAccounts() {

View File

@ -116,6 +116,12 @@ MockStorage.prototype = Object.freeze({
get: function () {
return Promise.resolve(this.data);
},
getOAuthTokens() {
return Promise.resolve(null);
},
setOAuthTokens(contents) {
return Promise.resolve();
},
});
/*
@ -726,6 +732,109 @@ add_test(function test_getOAuthToken() {
});
add_test(function test_getOAuthTokenScoped() {
let fxa = new MockFxAccounts();
let alice = getTestUser("alice");
alice.verified = true;
let getTokenFromAssertionCalled = false;
fxa.internal._d_signCertificate.resolve("cert1");
// create a mock oauth client
let client = new FxAccountsOAuthGrantClient({
serverURL: "http://example.com/v1",
client_id: "abc123"
});
client.getTokenFromAssertion = function (assertion, scopeString) {
equal(scopeString, "foo bar");
getTokenFromAssertionCalled = true;
return Promise.resolve({ access_token: "token" });
};
fxa.setSignedInUser(alice).then(
() => {
fxa.getOAuthToken({ scope: ["foo", "bar"], client: client }).then(
(result) => {
do_check_true(getTokenFromAssertionCalled);
do_check_eq(result, "token");
run_next_test();
}
)
}
);
});
add_task(function* test_getOAuthTokenCached() {
let fxa = new MockFxAccounts();
let alice = getTestUser("alice");
alice.verified = true;
let numTokenFromAssertionCalls = 0;
fxa.internal._d_signCertificate.resolve("cert1");
// create a mock oauth client
let client = new FxAccountsOAuthGrantClient({
serverURL: "http://example.com/v1",
client_id: "abc123"
});
client.getTokenFromAssertion = function () {
numTokenFromAssertionCalls += 1;
return Promise.resolve({ access_token: "token" });
};
yield fxa.setSignedInUser(alice);
let result = yield fxa.getOAuthToken({ scope: "profile", client: client, service: "test-service" });
do_check_eq(numTokenFromAssertionCalls, 1);
do_check_eq(result, "token");
// requesting it again should not re-fetch the token.
result = yield fxa.getOAuthToken({ scope: "profile", client: client, service: "test-service" });
do_check_eq(numTokenFromAssertionCalls, 1);
do_check_eq(result, "token");
// But requesting the same service and a different scope *will* get a new one.
result = yield fxa.getOAuthToken({ scope: "something-else", client: client, service: "test-service" });
do_check_eq(numTokenFromAssertionCalls, 2);
do_check_eq(result, "token");
});
add_task(function* test_getOAuthTokenCachedScopeNormalization() {
let fxa = new MockFxAccounts();
let alice = getTestUser("alice");
alice.verified = true;
let numTokenFromAssertionCalls = 0;
fxa.internal._d_signCertificate.resolve("cert1");
// create a mock oauth client
let client = new FxAccountsOAuthGrantClient({
serverURL: "http://example.com/v1",
client_id: "abc123"
});
client.getTokenFromAssertion = function () {
numTokenFromAssertionCalls += 1;
return Promise.resolve({ access_token: "token" });
};
yield fxa.setSignedInUser(alice);
let result = yield fxa.getOAuthToken({ scope: ["foo", "bar"], client: client, service: "test-service" });
do_check_eq(numTokenFromAssertionCalls, 1);
do_check_eq(result, "token");
// requesting it again with the scope array in a different order not re-fetch the token.
result = yield fxa.getOAuthToken({ scope: ["bar", "foo"], client: client, service: "test-service" });
do_check_eq(numTokenFromAssertionCalls, 1);
do_check_eq(result, "token");
// requesting it again with the scope array in different case not re-fetch the token.
result = yield fxa.getOAuthToken({ scope: ["Bar", "Foo"], client: client, service: "test-service" });
do_check_eq(numTokenFromAssertionCalls, 1);
do_check_eq(result, "token");
// But requesting with a new entry in the array does fetch one.
result = yield fxa.getOAuthToken({ scope: ["foo", "bar", "etc"], client: client, service: "test-service" });
do_check_eq(numTokenFromAssertionCalls, 2);
do_check_eq(result, "token");
});
Services.prefs.setCharPref("identity.fxaccounts.remote.oauth.uri", "https://example.com/v1");
add_test(function test_getOAuthToken_invalid_param() {
@ -738,6 +847,16 @@ add_test(function test_getOAuthToken_invalid_param() {
});
});
add_test(function test_getOAuthToken_invalid_scope_array() {
let fxa = new MockFxAccounts();
fxa.getOAuthToken({scope: []})
.then(null, err => {
do_check_eq(err.message, "INVALID_PARAMETER");
run_next_test();
});
});
add_test(function test_getOAuthToken_misconfigure_oauth_uri() {
let fxa = new MockFxAccounts();

View File

@ -83,6 +83,17 @@ add_test(function successfulResponse () {
);
});
add_test(function successfulDestroy () {
let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS);
let response = {
success: true,
status: STATUS_SUCCESS,
body: "{}",
};
client._Request = new mockResponse(response);
client.destroyToken("deadbeef").then(run_next_test);
});
add_test(function parseErrorResponse () {
let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS);

View File

@ -0,0 +1,73 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
// A test of FxAccountsOAuthGrantClient but using a real server it can
// hit.
"use strict";
Cu.import("resource://gre/modules/FxAccountsCommon.js");
Cu.import("resource://gre/modules/FxAccountsOAuthGrantClient.jsm");
// handlers for our server.
let numTokenFetches;
let activeTokens;
function authorize(request, response) {
response.setStatusLine("1.1", 200, "OK");
let token = "token" + numTokenFetches;
numTokenFetches += 1;
activeTokens.add(token);
response.write(JSON.stringify({access_token: token}));
}
function destroy(request, response) {
// Getting the body seems harder than it should be!
let sis = Cc["@mozilla.org/scriptableinputstream;1"]
.createInstance(Ci.nsIScriptableInputStream);
sis.init(request.bodyInputStream);
let body = JSON.parse(sis.read(sis.available()));
sis.close();
let token = body.token;
ok(activeTokens.delete(token));
print("after destroy have", activeTokens.size, "tokens left.")
response.setStatusLine("1.1", 200, "OK");
response.write('{}');
}
function startServer() {
numTokenFetches = 0;
activeTokens = new Set();
let srv = new HttpServer();
srv.registerPathHandler("/v1/authorization", authorize);
srv.registerPathHandler("/v1/destroy", destroy);
srv.start(-1);
return srv;
}
function promiseStopServer(server) {
return new Promise(resolve => {
server.stop(resolve);
});
}
add_task(function getAndRevokeToken () {
let server = startServer();
let clientOptions = {
serverURL: "http://localhost:" + server.identity.primaryPort + "/v1",
client_id: 'abc123',
}
let client = new FxAccountsOAuthGrantClient(clientOptions);
let result = yield client.getTokenFromAssertion("assertion", "scope");
equal(result.access_token, "token0");
equal(numTokenFetches, 1, "we hit the server to fetch a token");
yield client.destroyToken("token0");
equal(activeTokens.size, 0, "We hit the server to revoke it");
yield promiseStopServer(server);
});
// XXX - TODO - we should probably add more tests for unexpected responses etc.
function run_test() {
run_next_test();
}

View File

@ -0,0 +1,213 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
Cu.import("resource://gre/modules/FxAccounts.jsm");
Cu.import("resource://gre/modules/FxAccountsClient.jsm");
Cu.import("resource://gre/modules/FxAccountsCommon.js");
Cu.import("resource://gre/modules/osfile.jsm");
function promiseNotification(topic) {
return new Promise(resolve => {
let observe = () => {
Services.obs.removeObserver(observe, topic);
resolve();
}
Services.obs.addObserver(observe, topic, false);
});
}
// Just enough mocks so we can avoid hawk etc.
function MockFxAccountsClient() {
this._email = "nobody@example.com";
this._verified = false;
this.accountStatus = function(uid) {
let deferred = Promise.defer();
deferred.resolve(!!uid && (!this._deletedOnServer));
return deferred.promise;
};
this.signOut = function() { return Promise.resolve(); };
FxAccountsClient.apply(this);
}
MockFxAccountsClient.prototype = {
__proto__: FxAccountsClient.prototype
}
function MockFxAccounts() {
return new FxAccounts({
fxAccountsClient: new MockFxAccountsClient(),
});
}
function* createMockFxA() {
let fxa = new MockFxAccounts();
let credentials = {
email: "foo@example.com",
uid: "1234@lcip.org",
assertion: "foobar",
sessionToken: "dead",
kA: "beef",
kB: "cafe",
verified: true
};
yield fxa.setSignedInUser(credentials);
return fxa;
}
// The tests.
function run_test() {
run_next_test();
}
add_task(function testCacheStorage() {
let fxa = yield createMockFxA();
// Hook what the impl calls to save to disk.
let cas = fxa.internal.currentAccountState;
let origPersistCached = cas._persistCachedTokens.bind(cas)
cas._persistCachedTokens = function() {
return origPersistCached().then(() => {
Services.obs.notifyObservers(null, "testhelper-fxa-cache-persist-done", null);
});
};
let promiseWritten = promiseNotification("testhelper-fxa-cache-persist-done");
let tokenData = {token: "token1", somethingelse: "something else"};
let scopeArray = ["foo", "bar"];
cas.setCachedToken(scopeArray, tokenData);
deepEqual(cas.getCachedToken(scopeArray), tokenData);
deepEqual(cas.getAllCachedTokens(), [tokenData]);
// wait for background write to complete.
yield promiseWritten;
// Check the token cache was written to signedInUserOAuthTokens.json.
let path = OS.Path.join(OS.Constants.Path.profileDir, DEFAULT_OAUTH_TOKENS_FILENAME);
let data = yield CommonUtils.readJSON(path);
ok(data.tokens, "the data is in the json");
equal(data.uid, "1234@lcip.org", "The user's uid is in the json");
// Check it's all in the json.
let expectedKey = "bar|foo";
let entry = data.tokens[expectedKey];
ok(entry, "our key is in the json");
deepEqual(entry, tokenData, "correct token is in the json");
// Drop the token from the cache and ensure it is removed from the json.
promiseWritten = promiseNotification("testhelper-fxa-cache-persist-done");
yield cas.removeCachedToken("token1");
deepEqual(cas.getAllCachedTokens(), []);
yield promiseWritten;
data = yield CommonUtils.readJSON(path);
ok(!data.tokens[expectedKey], "our key was removed from the json");
// sign out and the token storage should end up with null.
yield fxa.signOut( /* localOnly = */ true);
data = yield CommonUtils.readJSON(path);
ok(data === null, "data wiped on signout");
});
// Test that the tokens are available after a full read of credentials from disk.
add_task(function testCacheAfterRead() {
let fxa = yield createMockFxA();
// Hook what the impl calls to save to disk.
let cas = fxa.internal.currentAccountState;
let origPersistCached = cas._persistCachedTokens.bind(cas)
cas._persistCachedTokens = function() {
return origPersistCached().then(() => {
Services.obs.notifyObservers(null, "testhelper-fxa-cache-persist-done", null);
});
};
let promiseWritten = promiseNotification("testhelper-fxa-cache-persist-done");
let tokenData = {token: "token1", somethingelse: "something else"};
let scopeArray = ["foo", "bar"];
cas.setCachedToken(scopeArray, tokenData);
yield promiseWritten;
// trick things so the data is re-read from disk.
cas.signedInUser = null;
cas.oauthTokens = null;
yield cas.getUserAccountData();
ok(cas.oauthTokens, "token data was re-read");
deepEqual(cas.getCachedToken(scopeArray), tokenData);
});
// Test that the tokens are saved after we read user credentials from disk.
add_task(function testCacheAfterRead() {
let fxa = yield createMockFxA();
// Hook what the impl calls to save to disk.
let cas = fxa.internal.currentAccountState;
let origPersistCached = cas._persistCachedTokens.bind(cas)
// trick things so that FxAccounts is in the mode where we're reading data
// from disk each time getSignedInUser() is called (ie, where .signedInUser
// remains null)
cas.signedInUser = null;
cas.oauthTokens = null;
yield cas.getUserAccountData();
// hook our "persist" function.
cas._persistCachedTokens = function() {
return origPersistCached().then(() => {
Services.obs.notifyObservers(null, "testhelper-fxa-cache-persist-done", null);
});
};
let promiseWritten = promiseNotification("testhelper-fxa-cache-persist-done");
// save a new token - it should be persisted.
let tokenData = {token: "token1", somethingelse: "something else"};
let scopeArray = ["foo", "bar"];
cas.setCachedToken(scopeArray, tokenData);
yield promiseWritten;
// re-read the tokens directly from the storage to ensure they were persisted.
let got = yield cas.signedInUserStorage.getOAuthTokens();
ok(got, "got persisted data");
ok(got.tokens, "have tokens");
// this is internal knowledge of how scopes get turned into "keys", but that's OK
ok(got.tokens["bar|foo"], "have our scope");
equal(got.tokens["bar|foo"].token, "token1", "have our token");
});
// Test that the tokens are ignored when the token storage has an incorrect uid.
add_task(function testCacheAfterReadBadUID() {
let fxa = yield createMockFxA();
// Hook what the impl calls to save to disk.
let cas = fxa.internal.currentAccountState;
let origPersistCached = cas._persistCachedTokens.bind(cas)
cas._persistCachedTokens = function() {
return origPersistCached().then(() => {
Services.obs.notifyObservers(null, "testhelper-fxa-cache-persist-done", null);
});
};
let promiseWritten = promiseNotification("testhelper-fxa-cache-persist-done");
let tokenData = {token: "token1", somethingelse: "something else"};
let scopeArray = ["foo", "bar"];
cas.setCachedToken(scopeArray, tokenData);
yield promiseWritten;
// trick things so the data is re-read from disk.
cas.signedInUser = null;
cas.oauthTokens = null;
// re-write the tokens data with an invalid UID.
let path = OS.Path.join(OS.Constants.Path.profileDir, DEFAULT_OAUTH_TOKENS_FILENAME);
let data = yield CommonUtils.readJSON(path);
ok(data.tokens, "the data is in the json");
equal(data.uid, "1234@lcip.org", "The user's uid is in the json");
data.uid = "someone_else";
yield CommonUtils.writeJSON(data, path);
yield cas.getUserAccountData();
deepEqual(cas.oauthTokens, {}, "token data ignored due to bad uid");
equal(null, cas.getCachedToken(scopeArray), "no token available");
});

View File

@ -0,0 +1,193 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
Cu.import("resource://gre/modules/FxAccounts.jsm");
Cu.import("resource://gre/modules/FxAccountsClient.jsm");
Cu.import("resource://gre/modules/FxAccountsCommon.js");
Cu.import("resource://gre/modules/FxAccountsOAuthGrantClient.jsm");
Cu.import("resource://services-common/utils.js");
function promiseNotification(topic) {
return new Promise(resolve => {
let observe = () => {
Services.obs.removeObserver(observe, topic);
resolve();
}
Services.obs.addObserver(observe, topic, false);
});
}
// Just enough mocks so we can avoid hawk etc.
function MockFxAccountsClient() {
this._email = "nobody@example.com";
this._verified = false;
this.accountStatus = function(uid) {
let deferred = Promise.defer();
deferred.resolve(!!uid && (!this._deletedOnServer));
return deferred.promise;
};
this.signOut = function() { return Promise.resolve(); };
FxAccountsClient.apply(this);
}
MockFxAccountsClient.prototype = {
__proto__: FxAccountsClient.prototype
}
function MockFxAccounts(mockGrantClient) {
return new FxAccounts({
fxAccountsClient: new MockFxAccountsClient(),
getAssertion: () => Promise.resolve("assertion"),
_destroyOAuthToken: function(tokenData) {
// somewhat sad duplication of _destroyOAuthToken, but hard to avoid.
return mockGrantClient.destroyToken(tokenData.token).then( () => {
Services.obs.notifyObservers(null, "testhelper-fxa-revoke-complete", null);
});
},
});
}
function* createMockFxA(mockGrantClient) {
let fxa = new MockFxAccounts(mockGrantClient);
let credentials = {
email: "foo@example.com",
uid: "1234@lcip.org",
assertion: "foobar",
sessionToken: "dead",
kA: "beef",
kB: "cafe",
verified: true
};
yield fxa.setSignedInUser(credentials);
return fxa;
}
// The tests.
function run_test() {
run_next_test();
}
function MockFxAccountsOAuthGrantClient() {
this.activeTokens = new Set();
}
MockFxAccountsOAuthGrantClient.prototype = {
serverURL: {href: "http://localhost"},
getTokenFromAssertion(assertion, scope) {
let token = "token" + this.numTokenFetches;
this.numTokenFetches += 1;
this.activeTokens.add(token);
print("getTokenFromAssertion returning token", token);
return Promise.resolve({access_token: token});
},
destroyToken(token) {
ok(this.activeTokens.delete(token));
print("after destroy have", this.activeTokens.size, "tokens left.");
return Promise.resolve({});
},
// and some stuff used only for tests.
numTokenFetches: 0,
activeTokens: null,
}
add_task(function testRevoke() {
let client = new MockFxAccountsOAuthGrantClient();
let tokenOptions = { scope: "test-scope", client: client };
let fxa = yield createMockFxA(client);
// get our first token and check we hit the mock.
let token1 = yield fxa.getOAuthToken(tokenOptions);
equal(client.numTokenFetches, 1);
equal(client.activeTokens.size, 1);
ok(token1, "got a token");
equal(token1, "token0");
// drop the new token from our cache.
yield fxa.removeCachedOAuthToken({token: token1});
// FxA fires an observer when the "background" revoke is complete.
yield promiseNotification("testhelper-fxa-revoke-complete");
// the revoke should have been successful.
equal(client.activeTokens.size, 0);
// fetching it again hits the server.
let token2 = yield fxa.getOAuthToken(tokenOptions);
equal(client.numTokenFetches, 2);
equal(client.activeTokens.size, 1);
ok(token2, "got a token");
notEqual(token1, token2, "got a different token");
});
add_task(function testSignOutDestroysTokens() {
let client = new MockFxAccountsOAuthGrantClient();
let fxa = yield createMockFxA(client);
// get our first token and check we hit the mock.
let token1 = yield fxa.getOAuthToken({ scope: "test-scope", client: client });
equal(client.numTokenFetches, 1);
equal(client.activeTokens.size, 1);
ok(token1, "got a token");
// get another
let token2 = yield fxa.getOAuthToken({ scope: "test-scope-2", client: client });
equal(client.numTokenFetches, 2);
equal(client.activeTokens.size, 2);
ok(token2, "got a token");
notEqual(token1, token2, "got a different token");
// now sign out - they should be removed.
yield fxa.signOut();
// FxA fires an observer when the "background" signout is complete.
yield promiseNotification("testhelper-fxa-signout-complete");
// No active tokens left.
equal(client.activeTokens.size, 0);
});
add_task(function testTokenRaces() {
// Here we do 2 concurrent fetches each for 2 different token scopes (ie,
// 4 token fetches in total).
// This should provoke a potential race in the token fetching but we should
// handle and detect that leaving us with one of the fetch tokens being
// revoked and the same token value returned to both calls.
let client = new MockFxAccountsOAuthGrantClient();
let fxa = yield createMockFxA(client);
// We should see 2 notifications as part of this - set up the listeners
// now (and wait on them later)
let notifications = Promise.all([
promiseNotification("testhelper-fxa-revoke-complete"),
promiseNotification("testhelper-fxa-revoke-complete"),
]);
let results = yield Promise.all([
fxa.getOAuthToken({scope: "test-scope", client: client}),
fxa.getOAuthToken({scope: "test-scope", client: client}),
fxa.getOAuthToken({scope: "test-scope-2", client: client}),
fxa.getOAuthToken({scope: "test-scope-2", client: client}),
]);
equal(client.numTokenFetches, 4, "should have fetched 4 tokens.");
// We should see 2 of the 4 revoked due to the race.
yield notifications;
// Should have 2 unique tokens
results.sort();
equal(results[0], results[1]);
equal(results[2], results[3]);
// should be 2 active.
equal(client.activeTokens.size, 2);
// Which can each be revoked.
notifications = Promise.all([
promiseNotification("testhelper-fxa-revoke-complete"),
promiseNotification("testhelper-fxa-revoke-complete"),
]);
yield fxa.removeCachedOAuthToken({token: results[0]});
equal(client.activeTokens.size, 1);
yield fxa.removeCachedOAuthToken({token: results[2]});
equal(client.activeTokens.size, 0);
yield notifications;
});

View File

@ -14,6 +14,9 @@ skip-if = appname != 'b2g'
reason = FxAccountsManager is only available for B2G for now
[test_oauth_client.js]
[test_oauth_grant_client.js]
[test_oauth_grant_client_server.js]
[test_oauth_tokens.js]
[test_oauth_token_storage.js]
[test_profile_client.js]
[test_profile_channel.js]
[test_profile.js]