mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-11-24 21:31:04 +00:00
Bug 1139743 (part 3) - Cache OAuth tokens. r=ckarlof
This commit is contained in:
parent
778766acf8
commit
f6ba77b103
@ -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});
|
||||
},
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
*
|
||||
|
@ -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() {
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
}
|
213
services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js
Normal file
213
services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js
Normal 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");
|
||||
});
|
193
services/fxaccounts/tests/xpcshell/test_oauth_tokens.js
Normal file
193
services/fxaccounts/tests/xpcshell/test_oauth_tokens.js
Normal 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;
|
||||
});
|
@ -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]
|
||||
|
Loading…
Reference in New Issue
Block a user