gecko-dev/services/fxaccounts/FxAccounts.jsm

1844 строки
65 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
var EXPORTED_SYMBOLS = ["fxAccounts", "FxAccounts"];
Cu.importGlobalProperties(["URL"]);
ChromeUtils.import("resource://gre/modules/Log.jsm");
ChromeUtils.import("resource://gre/modules/PromiseUtils.jsm");
ChromeUtils.import("resource://services-common/utils.js");
ChromeUtils.import("resource://services-common/rest.js");
ChromeUtils.import("resource://services-crypto/utils.js");
ChromeUtils.import("resource://gre/modules/Services.jsm");
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
ChromeUtils.import("resource://gre/modules/Timer.jsm");
ChromeUtils.import("resource://gre/modules/FxAccountsStorage.jsm");
ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
ChromeUtils.defineModuleGetter(this, "FxAccountsClient",
"resource://gre/modules/FxAccountsClient.jsm");
ChromeUtils.defineModuleGetter(this, "FxAccountsConfig",
"resource://gre/modules/FxAccountsConfig.jsm");
ChromeUtils.defineModuleGetter(this, "jwcrypto",
"resource://services-crypto/jwcrypto.jsm");
ChromeUtils.defineModuleGetter(this, "FxAccountsOAuthGrantClient",
"resource://gre/modules/FxAccountsOAuthGrantClient.jsm");
ChromeUtils.defineModuleGetter(this, "FxAccountsProfile",
"resource://gre/modules/FxAccountsProfile.jsm");
ChromeUtils.defineModuleGetter(this, "Utils",
"resource://services-sync/util.js");
XPCOMUtils.defineLazyPreferenceGetter(this, "FXA_ENABLED",
"identity.fxaccounts.enabled", true);
// All properties exposed by the public FxAccounts API.
var publicProperties = [
"accountStatus",
"canGetKeys",
"checkVerificationStatus",
"getAccountsClient",
"getAssertion",
"getDeviceId",
"getDeviceList",
"getKeys",
"getOAuthToken",
"getProfileCache",
"getSignedInUser",
"getSignedInUserProfile",
"handleAccountDestroyed",
"handleDeviceDisconnection",
"handleEmailUpdated",
"hasLocalSession",
"invalidateCertificate",
"loadAndPoll",
"localtimeOffsetMsec",
"notifyDevices",
"now",
"removeCachedOAuthToken",
"resendVerificationEmail",
"resetCredentials",
"sessionStatus",
"setProfileCache",
"setSignedInUser",
"signOut",
"updateDeviceRegistration",
"deleteDeviceRegistration",
"updateUserAccountData",
"whenVerified",
];
// An AccountState object holds all state related to one specific account.
// Only one AccountState is ever "current" in the FxAccountsInternal object -
// whenever a user logs out or logs in, the current AccountState is discarded,
// making it impossible for the wrong state or state data to be accidentally
// used.
// In addition, it has some promise-related helpers to ensure that if an
// attempt is made to resolve a promise on a "stale" state (eg, if an
// operation starts, but a different user logs in before the operation
// completes), the promise will be rejected.
// It is intended to be used thusly:
// somePromiseBasedFunction: function() {
// let currentState = this.currentAccountState;
// return someOtherPromiseFunction().then(
// data => currentState.resolve(data)
// );
// }
// If the state has changed between the function being called and the promise
// being resolved, the .resolve() call will actually be rejected.
var AccountState = this.AccountState = function(storageManager) {
this.storageManager = storageManager;
this.promiseInitialized = this.storageManager.getAccountData().then(data => {
this.oauthTokens = data && data.oauthTokens ? data.oauthTokens : {};
}).catch(err => {
log.error("Failed to initialize the storage manager", err);
// Things are going to fall apart, but not much we can do about it here.
});
};
AccountState.prototype = {
oauthTokens: null,
whenVerifiedDeferred: null,
whenKeysReadyDeferred: null,
// If the storage manager has been nuked then we are no longer current.
get isCurrent() {
return this.storageManager != null;
},
abort() {
if (this.whenVerifiedDeferred) {
this.whenVerifiedDeferred.reject(
new Error("Verification aborted; Another user signing in"));
this.whenVerifiedDeferred = null;
}
if (this.whenKeysReadyDeferred) {
this.whenKeysReadyDeferred.reject(
new Error("Verification aborted; Another user signing in"));
this.whenKeysReadyDeferred = null;
}
this.cert = null;
this.keyPair = null;
this.oauthTokens = null;
// Avoid finalizing the storageManager multiple times (ie, .signOut()
// followed by .abort())
if (!this.storageManager) {
return Promise.resolve();
}
let storageManager = this.storageManager;
this.storageManager = null;
return storageManager.finalize();
},
// Clobber all cached data and write that empty data to storage.
signOut() {
this.cert = null;
this.keyPair = null;
this.oauthTokens = null;
let storageManager = this.storageManager;
this.storageManager = null;
return storageManager.deleteAccountData().then(() => {
return storageManager.finalize();
});
},
// Get user account data. Optionally specify explicit field names to fetch
// (and note that if you require an in-memory field you *must* specify the
// field name(s).)
getUserAccountData(fieldNames = null) {
if (!this.isCurrent) {
return Promise.reject(new Error("Another user has signed in"));
}
return this.storageManager.getAccountData(fieldNames).then(result => {
return this.resolve(result);
});
},
updateUserAccountData(updatedFields) {
if (!this.isCurrent) {
return Promise.reject(new Error("Another user has signed in"));
}
return this.storageManager.updateAccountData(updatedFields);
},
resolve(result) {
if (!this.isCurrent) {
log.info("An accountState promise was resolved, but was actually rejected" +
" due to a different user being signed in. Originally resolved" +
" with", result);
return Promise.reject(new Error("A different user signed in"));
}
return Promise.resolve(result);
},
reject(error) {
// It could be argued that we should just let it reject with the original
// error - but this runs the risk of the error being (eg) a 401, which
// might cause the consumer to attempt some remediation and cause other
// problems.
if (!this.isCurrent) {
log.info("An accountState promise was rejected, but we are ignoring that" +
"reason and rejecting it due to a different user being signed in." +
"Originally rejected with", error);
return Promise.reject(new Error("A different user signed in"));
}
return Promise.reject(error);
},
// Abstractions for storage of cached tokens - these are all sync, and don't
// handle revocation etc - it's just storage (and the storage itself is async,
// but we don't return the storage promises, so it *looks* sync)
// These functions are sync simply so we can handle "token races" - when there
// are multiple in-flight requests for the same scope, we can detect this
// and revoke the redundant token.
// 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);
let result = this.oauthTokens[key];
if (result) {
// 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 result;
}
return null;
},
// 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] of Object.entries(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();
return this.updateUserAccountData({ oauthTokens: this.oauthTokens }).catch(err => {
log.error("Failed to update cached tokens", 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("|");
}
function getPropertyDescriptor(obj, prop) {
return Object.getOwnPropertyDescriptor(obj, prop) ||
getPropertyDescriptor(Object.getPrototypeOf(obj), prop);
}
/**
* Copies properties from a given object to another object.
*
* @param from (object)
* The object we read property descriptors from.
* @param to (object)
* The object that we set property descriptors on.
* @param thisObj (object)
* The object that will be used to .bind() all function properties we find to.
* @param keys ([...])
* The names of all properties to be copied.
*/
function copyObjectProperties(from, to, thisObj, keys) {
for (let prop of keys) {
// Look for the prop in the prototype chain.
let desc = getPropertyDescriptor(from, prop);
if (typeof(desc.value) == "function") {
desc.value = desc.value.bind(thisObj);
}
if (desc.get) {
desc.get = desc.get.bind(thisObj);
}
if (desc.set) {
desc.set = desc.set.bind(thisObj);
}
Object.defineProperty(to, prop, desc);
}
}
function urlsafeBase64Encode(key) {
return ChromeUtils.base64URLEncode(new Uint8Array(key), { pad: false });
}
/**
* The public API's constructor.
*/
var FxAccounts = function(mockInternal) {
let external = {};
let internal;
if (!mockInternal) {
internal = new FxAccountsInternal();
copyObjectProperties(FxAccountsInternal.prototype, external, internal, publicProperties);
} else {
internal = Object.create(FxAccountsInternal.prototype, Object.getOwnPropertyDescriptors(mockInternal));
copyObjectProperties(internal, external, internal, publicProperties);
// Exposes the internal object for testing only.
external.internal = internal;
}
if (!internal.fxaPushService) {
// internal.fxaPushService option is used in testing.
// Otherwise we load the service lazily.
XPCOMUtils.defineLazyGetter(internal, "fxaPushService", function() {
return Cc["@mozilla.org/fxaccounts/push;1"]
.getService(Ci.nsISupports)
.wrappedJSObject;
});
}
if (!internal.observerPreloads) {
// A registry of promise-returning functions that `notifyObservers` should
// call before sending notifications. Primarily used so parts of Firefox
// which have yet to load for performance reasons can be force-loaded, and
// thus not miss notifications.
internal.observerPreloads = [
// Sync
() => {
let scope = {};
ChromeUtils.import("resource://services-sync/main.js", scope);
return scope.Weave.Service.promiseInitialized;
},
];
}
// wait until after the mocks are setup before initializing.
internal.initialize();
return Object.freeze(external);
};
this.FxAccounts.config = FxAccountsConfig;
/**
* The internal API's constructor.
*/
function FxAccountsInternal() {
// Make a local copy of this constant so we can mock it in testing
this.POLL_SESSION = POLL_SESSION;
// All significant initialization should be done in the initialize() method
// below as it helps with testing.
}
/**
* The internal API's prototype.
*/
FxAccountsInternal.prototype = {
// The timeout (in ms) we use to poll for a verified mail for the first
// VERIFICATION_POLL_START_SLOWDOWN_THRESHOLD minutes if the user has
// logged-in in this session.
VERIFICATION_POLL_TIMEOUT_INITIAL: 60000, // 1 minute.
// All the other cases (> 5 min, on restart etc).
VERIFICATION_POLL_TIMEOUT_SUBSEQUENT: 5 * 60000, // 5 minutes.
// After X minutes, the polling will slow down to _SUBSEQUENT if we have
// logged-in in this session.
VERIFICATION_POLL_START_SLOWDOWN_THRESHOLD: 5,
// The current version of the device registration, we use this to re-register
// devices after we update what we send on device registration.
DEVICE_REGISTRATION_VERSION: 2,
_fxAccountsClient: null,
// All significant initialization should be done in this initialize() method,
// as it's called after this object has been mocked for tests.
initialize() {
this.currentTimer = null;
this.currentAccountState = this.newAccountState();
},
get fxAccountsClient() {
if (!this._fxAccountsClient) {
this._fxAccountsClient = new FxAccountsClient();
}
return this._fxAccountsClient;
},
// The profile object used to fetch the actual user profile.
_profile: null,
get profile() {
if (!this._profile) {
let profileServerUrl = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.profile.uri");
this._profile = new FxAccountsProfile({
fxa: this,
profileServerUrl,
});
}
return this._profile;
},
// A hook-point for tests who may want a mocked AccountState or mocked storage.
newAccountState(credentials) {
let storage = new FxAccountsStorageManager();
storage.initialize(credentials);
return new AccountState(storage);
},
/**
* Send a message to a set of devices in the same account
*
* @return Promise
*/
notifyDevices(deviceIds, excludedIds, payload, TTL) {
if (typeof deviceIds == "string") {
deviceIds = [deviceIds];
}
return this.currentAccountState.getUserAccountData()
.then(data => {
if (!data) {
throw this._error(ERROR_NO_ACCOUNT);
}
if (!data.sessionToken) {
throw this._error(ERROR_AUTH_ERROR,
"notifyDevices called without a session token");
}
return this.fxAccountsClient.notifyDevices(data.sessionToken, deviceIds,
excludedIds, payload, TTL);
});
},
/**
* Return the current time in milliseconds as an integer. Allows tests to
* manipulate the date to simulate certificate expiration.
*/
now() {
return this.fxAccountsClient.now();
},
getAccountsClient() {
return this.fxAccountsClient;
},
/**
* Return clock offset in milliseconds, as reported by the fxAccountsClient.
* This can be overridden for testing.
*
* The offset is the number of milliseconds that must be added to the client
* clock to make it equal to the server clock. For example, if the client is
* five minutes ahead of the server, the localtimeOffsetMsec will be -300000.
*/
get localtimeOffsetMsec() {
return this.fxAccountsClient.localtimeOffsetMsec;
},
/**
* Ask the server whether the user's email has been verified
*/
checkEmailStatus: function checkEmailStatus(sessionToken, options = {}) {
if (!sessionToken) {
return Promise.reject(new Error(
"checkEmailStatus called without a session token"));
}
return this.fxAccountsClient.recoveryEmailStatus(sessionToken,
options).catch(error => this._handleTokenError(error));
},
/**
* Once the user's email is verified, we can request the keys
*/
fetchKeys: function fetchKeys(keyFetchToken) {
log.debug("fetchKeys: " + !!keyFetchToken);
if (logPII) {
log.debug("fetchKeys - the token is " + keyFetchToken);
}
return this.fxAccountsClient.accountKeys(keyFetchToken);
},
// set() makes sure that polling is happening, if necessary.
// get() does not wait for verification, and returns an object even if
// unverified. The caller of get() must check .verified .
// The "fxaccounts:onverified" event will fire only when the verified
// state goes from false to true, so callers must register their observer
// and then call get(). In particular, it will not fire when the account
// was found to be verified in a previous boot: if our stored state says
// the account is verified, the event will never fire. So callers must do:
// register notification observer (go)
// userdata = get()
// if (userdata.verified()) {go()}
/**
* Get the user currently signed in to Firefox Accounts.
*
* @return Promise
* The promise resolves to the credentials object of the signed-in user:
* {
* email: The user's email address
* uid: The user's unique id
* sessionToken: Session for the FxA server
* kSync: An encryption key for Sync
* kXCS: A key hash of kB for the X-Client-State header
* kExtSync: An encryption key for WebExtensions syncing
* kExtKbHash: A key hash of kB for WebExtensions syncing
* verified: email verification status
* authAt: The time (seconds since epoch) that this record was
* authenticated
* }
* or null if no user is signed in.
*/
async getSignedInUser() {
let currentState = this.currentAccountState;
const data = await currentState.getUserAccountData();
if (!data) {
return currentState.resolve(null);
}
if (!FXA_ENABLED) {
await this.signOut();
return currentState.resolve(null);
}
if (!this.isUserEmailVerified(data)) {
// If the email is not verified, start polling for verification,
// but return null right away. We don't want to return a promise
// that might not be fulfilled for a long time.
this.startVerifiedCheck(data);
}
return currentState.resolve(data);
},
/**
* Set the current user signed in to Firefox Accounts.
*
* @param credentials
* The credentials object obtained by logging in or creating
* an account on the FxA server:
* {
* authAt: The time (seconds since epoch) that this record was
* authenticated
* email: The users email address
* keyFetchToken: a keyFetchToken which has not yet been used
* sessionToken: Session for the FxA server
* uid: The user's unique id
* unwrapBKey: used to unwrap kB, derived locally from the
* password (not revealed to the FxA server)
* verified: true/false
* }
* @return Promise
* The promise resolves to null when the data is saved
* successfully and is rejected on error.
*/
async setSignedInUser(credentials) {
if (!FXA_ENABLED) {
throw new Error("Cannot call setSignedInUser when FxA is disabled.");
}
log.debug("setSignedInUser - aborting any existing flows");
const signedInUser = await this.getSignedInUser();
if (signedInUser) {
await this.deleteDeviceRegistration(signedInUser.sessionToken, signedInUser.deviceId);
}
await this.abortExistingFlow();
let currentAccountState = this.currentAccountState = this.newAccountState(
Cu.cloneInto(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 (although is that
// really something we should commit to? Why not let the write happen in
// the background? Already does for updateAccountData ;)
await currentAccountState.promiseInitialized;
// Starting point for polling if new user
if (!this.isUserEmailVerified(credentials)) {
this.startVerifiedCheck(credentials);
}
await this.updateDeviceRegistration();
Services.telemetry.getHistogramById("FXA_CONFIGURED").add(1);
await this.notifyObservers(ONLOGIN_NOTIFICATION);
return currentAccountState.resolve();
},
/**
* Update account data for the currently signed in user.
*
* @param credentials
* The credentials object containing the fields to be updated.
* This object must contain the |uid| field and it must
* match the currently signed in user.
*/
updateUserAccountData(credentials) {
log.debug("updateUserAccountData called with fields", Object.keys(credentials));
if (logPII) {
log.debug("updateUserAccountData called with data", credentials);
}
let currentAccountState = this.currentAccountState;
return currentAccountState.promiseInitialized.then(() => {
return currentAccountState.getUserAccountData(["uid"]);
}).then(existing => {
if (existing.uid != credentials.uid) {
throw new Error("The specified credentials aren't for the current user");
}
// We need to nuke uid as storage will complain if we try and
// update it (even when the value is the same)
credentials = Cu.cloneInto(credentials, {}); // clone it first
delete credentials.uid;
return currentAccountState.updateUserAccountData(credentials);
});
},
/**
* returns a promise that fires with the assertion. If there is no verified
* signed-in user, fires with null.
*/
getAssertion: function getAssertion(audience) {
return this._getAssertion(audience);
},
// getAssertion() is "public" so screws with our mock story. This
// implementation method *can* be (and is) mocked by tests.
_getAssertion: function _getAssertion(audience) {
log.debug("enter getAssertion()");
let currentState = this.currentAccountState;
return currentState.getUserAccountData().then(data => {
if (!data) {
// No signed-in user
return null;
}
if (!this.isUserEmailVerified(data)) {
// Signed-in user has not verified email
return null;
}
if (!data.sessionToken) {
// can't get a signed certificate without a session token. This
// can happen if we request an assertion after clearing an invalid
// session token from storage.
throw this._error(ERROR_AUTH_ERROR, "getAssertion called without a session token");
}
return this.getKeypairAndCertificate(currentState).then(
({keyPair, certificate}) => {
return this.getAssertionFromCert(data, keyPair, certificate, audience);
}
);
}).catch(err =>
this._handleTokenError(err)
).then(result => currentState.resolve(result));
},
/**
* Invalidate the FxA certificate, so that it will be refreshed from the server
* the next time it is needed.
*/
invalidateCertificate() {
return this.currentAccountState.updateUserAccountData({ cert: null });
},
getDeviceId() {
return this.currentAccountState.getUserAccountData()
.then(data => {
if (data) {
if (!data.deviceId || !data.deviceRegistrationVersion ||
data.deviceRegistrationVersion < this.DEVICE_REGISTRATION_VERSION) {
// There is no device id or the device registration is outdated.
// Either way, we should register the device with FxA
// before returning the id to the caller.
return this._registerOrUpdateDevice(data);
}
// Return the device id that we already registered with the server.
return data.deviceId;
}
// Without a signed-in user, there can be no device id.
return null;
});
},
async getDeviceList() {
let accountData = await this._getVerifiedAccountOrReject();
return this.fxAccountsClient.getDeviceList(accountData.sessionToken);
},
/**
* Resend the verification email fot the currently signed-in user.
*
*/
resendVerificationEmail: function resendVerificationEmail() {
let currentState = this.currentAccountState;
return this.getSignedInUser().then(data => {
// If the caller is asking for verification to be re-sent, and there is
// no signed-in user to begin with, this is probably best regarded as an
// error.
if (data) {
if (!data.sessionToken) {
return Promise.reject(new Error(
"resendVerificationEmail called without a session token"));
}
this.startPollEmailStatus(currentState, data.sessionToken, "start");
return this.fxAccountsClient.resendVerificationEmail(
data.sessionToken).catch(err => this._handleTokenError(err));
}
throw new Error("Cannot resend verification email; no signed-in user");
});
},
/*
* Reset state such that any previous flow is canceled.
*/
abortExistingFlow: function abortExistingFlow() {
if (this.currentTimer) {
log.debug("Polling aborted; Another user signing in");
clearTimeout(this.currentTimer);
this.currentTimer = 0;
}
if (this._profile) {
this._profile.tearDown();
this._profile = null;
}
// We "abort" the accountState and assume our caller is about to throw it
// away and replace it with a new one.
return this.currentAccountState.abort();
},
accountStatus: function accountStatus() {
return this.currentAccountState.getUserAccountData().then(data => {
if (!data) {
return false;
}
return this.fxAccountsClient.accountStatus(data.uid);
});
},
checkVerificationStatus() {
log.trace("checkVerificationStatus");
let currentState = this.currentAccountState;
return currentState.getUserAccountData().then(data => {
if (!data) {
log.trace("checkVerificationStatus - no user data");
return null;
}
// Always check the verification status, even if the local state indicates
// we're already verified. If the user changed their password, the check
// will fail, and we'll enter the reauth state.
log.trace("checkVerificationStatus - forcing verification status check");
return this.startPollEmailStatus(currentState, data.sessionToken, "push");
});
},
_destroyOAuthToken(tokenData) {
let client = new FxAccountsOAuthGrantClient({
serverURL: tokenData.server,
client_id: FX_OAUTH_CLIENT_ID
});
return client.destroyToken(tokenData.token);
},
_destroyAllOAuthTokens(tokenInfos) {
// let's just destroy them all in parallel...
let promises = [];
for (let tokenInfo of Object.values(tokenInfos || {})) {
promises.push(this._destroyOAuthToken(tokenInfo));
}
return Promise.all(promises);
},
signOut: function signOut(localOnly) {
let currentState = this.currentAccountState;
let sessionToken;
let tokensToRevoke;
let deviceId;
return currentState.getUserAccountData().then(data => {
// Save the session token, tokens to revoke and the
// device id for use in the call to signOut below.
if (data) {
sessionToken = data.sessionToken;
tokensToRevoke = data.oauthTokens;
deviceId = data.deviceId;
}
return this._signOutLocal();
}).then(() => {
// FxAccountsManager calls here, then does its own call
// to FxAccountsClient.signOut().
if (!localOnly) {
// Wrap this in a promise so *any* errors in signOut won't
// block the local sign out. This is *not* returned.
Promise.resolve().then(() => {
// This can happen in the background and shouldn't block
// the user from signing out. The server must tolerate
// clients just disappearing, so this call should be best effort.
if (sessionToken) {
return this._signOutServer(sessionToken, deviceId);
}
log.warn("Missing session token; skipping remote sign out");
return null;
}).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(() => {
FxAccountsConfig.resetConfigURLs();
// just for testing - notifications are cheap when no observers.
return this.notifyObservers("testhelper-fxa-signout-complete");
});
} else {
// We want to do this either way -- but if we're signing out remotely we
// need to wait until we destroy the oauth tokens if we want that to succeed.
FxAccountsConfig.resetConfigURLs();
}
}).then(() => {
return this.notifyObservers(ONLOGOUT_NOTIFICATION);
});
},
/**
* This function should be called in conjunction with a server-side
* signOut via FxAccountsClient.
*/
_signOutLocal: function signOutLocal() {
let currentAccountState = this.currentAccountState;
return currentAccountState.signOut().then(() => {
// this "aborts" this.currentAccountState but doesn't make a new one.
return this.abortExistingFlow();
}).then(() => {
this.currentAccountState = this.newAccountState();
return this.currentAccountState.promiseInitialized;
});
},
_signOutServer(sessionToken, deviceId) {
// For now we assume the service being logged out from is Sync, so
// we must tell the server to either destroy the device or sign out
// (if no device exists). We might need to revisit this when this
// FxA code is used in a context that isn't Sync.
const options = { service: "sync" };
if (deviceId) {
log.debug("destroying device, session and unsubscribing from FxA push");
return this.deleteDeviceRegistration(sessionToken, deviceId);
}
log.debug("destroying session");
return this.fxAccountsClient.signOut(sessionToken, options);
},
/**
* Check the status of the current session using cached credentials.
*
* @return Promise
* Resolves with a boolean indicating if the session is still valid
*/
sessionStatus() {
return this.getSignedInUser().then(data => {
if (!data.sessionToken) {
return Promise.reject(new Error(
"sessionStatus called without a session token"));
}
return this.fxAccountsClient.sessionStatus(data.sessionToken);
});
},
/**
* Checks if we have a valid local session state for the current account.
*
* @return Promise
* Resolves with a boolean, with true indicating that we appear to
* have a valid local session, or false if we need to reauthenticate
* with the content server to obtain one.
* Note that this doesn't check with the server - it really just tells
* us if we are even able to perform that server check. To fully check
* the account status, you should first call this method, and if this
* returns true, you should then call sessionStatus() to check with
* the server.
*/
async hasLocalSession() {
let data = await this.getSignedInUser();
return data && data.sessionToken;
},
/**
* Checks if we currently have encryption keys or if we have enough to
* be able to successfully fetch them for the signed-in-user.
*/
async canGetKeys() {
let currentState = this.currentAccountState;
let userData = await currentState.getUserAccountData();
if (!userData) {
throw new Error("Can't possibly get keys; User is not signed in");
}
// - keyFetchToken means we can almost certainly grab them.
// - kSync, kXCS, kExtSync and kExtKbHash means we already have them.
// - kB is deprecated but |getKeys| will help us migrate to kSync and friends.
return userData && (userData.keyFetchToken ||
DERIVED_KEYS_NAMES.every(k => userData[k]) ||
userData.kB);
},
/**
* Fetch encryption keys for the signed-in-user from the FxA API server.
*
* Not for user consumption. Exists to cause the keys to be fetch.
*
* Returns user data so that it can be chained with other methods.
*
* @return Promise
* The promise resolves to the credentials object of the signed-in user:
* {
* email: The user's email address
* uid: The user's unique id
* sessionToken: Session for the FxA server
* kSync: An encryption key for Sync
* kXCS: A key hash of kB for the X-Client-State header
* kExtSync: An encryption key for WebExtensions syncing
* kExtKbHash: A key hash of kB for WebExtensions syncing
* verified: email verification status
* }
* or null if no user is signed in
*/
async getKeys() {
let currentState = this.currentAccountState;
try {
let userData = await currentState.getUserAccountData();
if (!userData) {
throw new Error("Can't get keys; User is not signed in");
}
if (userData.kB) { // Bug 1426306 - Migrate from kB to derived keys.
log.info("Migrating kB to derived keys.");
const {uid, kB} = userData;
await this.updateUserAccountData({
uid,
...this._deriveKeys(uid, CommonUtils.hexToBytes(kB)),
kA: null, // Remove kA and kB from storage.
kB: null
});
userData = await this.getUserAccountData();
}
if (DERIVED_KEYS_NAMES.every(k => userData[k])) {
return currentState.resolve(userData);
}
if (!currentState.whenKeysReadyDeferred) {
currentState.whenKeysReadyDeferred = PromiseUtils.defer();
if (userData.keyFetchToken) {
this.fetchAndUnwrapKeys(userData.keyFetchToken).then(
(dataWithKeys) => {
if (DERIVED_KEYS_NAMES.some(k => !dataWithKeys[k])) {
const missing = DERIVED_KEYS_NAMES.filter(k => !dataWithKeys[k]);
currentState.whenKeysReadyDeferred.reject(
new Error(`user data missing: ${missing.join(", ")}`)
);
return;
}
currentState.whenKeysReadyDeferred.resolve(dataWithKeys);
},
(err) => {
currentState.whenKeysReadyDeferred.reject(err);
}
);
} else {
currentState.whenKeysReadyDeferred.reject("No keyFetchToken");
}
}
return await currentState.resolve(currentState.whenKeysReadyDeferred.promise);
} catch (err) {
return currentState.resolve(this._handleTokenError(err));
}
},
async fetchAndUnwrapKeys(keyFetchToken) {
if (logPII) {
log.debug("fetchAndUnwrapKeys: token: " + keyFetchToken);
}
let currentState = this.currentAccountState;
// Sign out if we don't have a key fetch token.
if (!keyFetchToken) {
log.warn("improper fetchAndUnwrapKeys() call: token missing");
await this.signOut();
return currentState.resolve(null);
}
let {wrapKB} = await this.fetchKeys(keyFetchToken);
let data = await currentState.getUserAccountData();
// Sanity check that the user hasn't changed out from under us
if (data.keyFetchToken !== keyFetchToken) {
throw new Error("Signed in user changed while fetching keys!");
}
// Next statements must be synchronous until we setUserAccountData
// so that we don't risk getting into a weird state.
let kBbytes = CryptoUtils.xor(CommonUtils.hexToBytes(data.unwrapBKey),
wrapKB);
if (logPII) {
log.debug("kBbytes: " + kBbytes);
}
let updateData = {
...this._deriveKeys(data.uid, kBbytes),
keyFetchToken: null, // null values cause the item to be removed.
unwrapBKey: null,
};
log.debug("Keys Obtained:" +
DERIVED_KEYS_NAMES.map(k => `${k}=${!!updateData[k]}`).join(", "));
if (logPII) {
log.debug("Keys Obtained:" +
DERIVED_KEYS_NAMES.map(k => `${k}=${updateData[k]}`).join(", "));
}
await currentState.updateUserAccountData(updateData);
// We are now ready for business. This should only be invoked once
// per setSignedInUser(), regardless of whether we've rebooted since
// setSignedInUser() was called.
await this.notifyObservers(ONVERIFIED_NOTIFICATION);
data = await currentState.getUserAccountData();
return currentState.resolve(data);
},
_deriveKeys(uid, kBbytes) {
return {
kSync: CommonUtils.bytesAsHex(this._deriveSyncKey(kBbytes)),
kXCS: CommonUtils.bytesAsHex(this._deriveXClientState(kBbytes)),
kExtSync: CommonUtils.bytesAsHex(this._deriveWebExtSyncStoreKey(kBbytes)),
kExtKbHash: CommonUtils.bytesAsHex(this._deriveWebExtKbHash(uid, kBbytes)),
};
},
/**
* Derive the Sync Key given the byte string kB.
*
* @returns HKDF(kB, undefined, "identity.mozilla.com/picl/v1/oldsync", 64)
*/
_deriveSyncKey(kBbytes) {
return CryptoUtils.hkdf(kBbytes, undefined,
"identity.mozilla.com/picl/v1/oldsync", 2 * 32);
},
/**
* Derive the WebExtensions Sync Storage Key given the byte string kB.
*
* @returns HKDF(kB, undefined, "identity.mozilla.com/picl/v1/chrome.storage.sync", 64)
*/
_deriveWebExtSyncStoreKey(kBbytes) {
return CryptoUtils.hkdf(kBbytes, undefined,
"identity.mozilla.com/picl/v1/chrome.storage.sync",
2 * 32);
},
/**
* Derive the WebExtensions kbHash given the byte string kB.
*
* @returns SHA256(uid + kB)
*/
_deriveWebExtKbHash(uid, kBbytes) {
return this._sha256(uid + kBbytes);
},
/**
* Derive the X-Client-State header given the byte string kB.
*
* @returns SHA256(kB)[:16]
*/
_deriveXClientState(kBbytes) {
return this._sha256(kBbytes).slice(0, 16);
},
_sha256(bytes) {
let hasher = Cc["@mozilla.org/security/hash;1"]
.createInstance(Ci.nsICryptoHash);
hasher.init(hasher.SHA256);
return CryptoUtils.digestBytes(bytes, hasher);
},
async getAssertionFromCert(data, keyPair, cert, audience) {
log.debug("getAssertionFromCert");
let options = {
duration: ASSERTION_LIFETIME,
localtimeOffsetMsec: this.localtimeOffsetMsec,
now: this.now()
};
let currentState = this.currentAccountState;
// "audience" should look like "http://123done.org".
// The generated assertion will expire in two minutes.
let assertion = await new Promise((resolve, reject) => {
jwcrypto.generateAssertion(cert, keyPair, audience, options, (err, signed) => {
if (err) {
log.error("getAssertionFromCert: " + err);
reject(err);
} else {
log.debug("getAssertionFromCert returning signed: " + !!signed);
if (logPII) {
log.debug("getAssertionFromCert returning signed: " + signed);
}
resolve(signed);
}
});
});
return currentState.resolve(assertion);
},
getCertificateSigned(sessionToken, serializedPublicKey, lifetime) {
log.debug("getCertificateSigned: " + !!sessionToken + " " + !!serializedPublicKey);
if (logPII) {
log.debug("getCertificateSigned: " + sessionToken + " " + serializedPublicKey);
}
return this.fxAccountsClient.signCertificate(
sessionToken,
JSON.parse(serializedPublicKey),
lifetime
);
},
/**
* returns a promise that fires with {keyPair, certificate}.
*/
async getKeypairAndCertificate(currentState) {
// If the debugging pref to ignore cached authentication credentials is set for Sync,
// then don't use any cached key pair/certificate, i.e., generate a new
// one and get it signed.
// The purpose of this pref is to expedite any auth errors as the result of a
// expired or revoked FxA session token, e.g., from resetting or changing the FxA
// password.
let ignoreCachedAuthCredentials = Services.prefs.getBoolPref("services.sync.debug.ignoreCachedAuthCredentials", false);
let mustBeValidUntil = this.now() + ASSERTION_USE_PERIOD;
let accountData = await currentState.getUserAccountData(["cert", "keyPair", "sessionToken"]);
let keyPairValid = !ignoreCachedAuthCredentials &&
accountData.keyPair &&
(accountData.keyPair.validUntil > mustBeValidUntil);
let certValid = !ignoreCachedAuthCredentials &&
accountData.cert &&
(accountData.cert.validUntil > mustBeValidUntil);
// TODO: get the lifetime from the cert's .exp field
if (keyPairValid && certValid) {
log.debug("getKeypairAndCertificate: already have keyPair and certificate");
return {
keyPair: accountData.keyPair.rawKeyPair,
certificate: accountData.cert.rawCert
};
}
// We are definately going to generate a new cert, either because it has
// already expired, or the keyPair has - and a new keyPair means we must
// generate a new cert.
// A keyPair has a longer lifetime than a cert, so it's possible we will
// have a valid keypair but an expired cert, which means we can skip
// keypair generation.
// Either way, the cert will require hitting the network, so bail now if
// we know that's going to fail.
if (Services.io.offline) {
throw new Error(ERROR_OFFLINE);
}
let keyPair;
if (keyPairValid) {
keyPair = accountData.keyPair;
} else {
let keyWillBeValidUntil = this.now() + KEY_LIFETIME;
keyPair = await new Promise((resolve, reject) => {
jwcrypto.generateKeyPair("DS160", (err, kp) => {
if (err) {
reject(err);
return;
}
log.debug("got keyPair");
resolve({
rawKeyPair: kp,
validUntil: keyWillBeValidUntil,
});
});
});
}
// and generate the cert.
let certWillBeValidUntil = this.now() + CERT_LIFETIME;
let certificate = await this.getCertificateSigned(accountData.sessionToken,
keyPair.rawKeyPair.serializedPublicKey,
CERT_LIFETIME);
log.debug("getCertificate got a new one: " + !!certificate);
if (certificate) {
// Cache both keypair and cert.
let toUpdate = {
keyPair,
cert: {
rawCert: certificate,
validUntil: certWillBeValidUntil,
},
};
await currentState.updateUserAccountData(toUpdate);
}
return {
keyPair: keyPair.rawKeyPair,
certificate,
};
},
getUserAccountData() {
return this.currentAccountState.getUserAccountData();
},
isUserEmailVerified: function isUserEmailVerified(data) {
return !!(data && data.verified);
},
/**
* Setup for and if necessary do email verification polling.
*/
loadAndPoll() {
let currentState = this.currentAccountState;
return currentState.getUserAccountData()
.then(data => {
if (data) {
Services.telemetry.getHistogramById("FXA_CONFIGURED").add(1);
if (!this.isUserEmailVerified(data)) {
this.startPollEmailStatus(currentState, data.sessionToken, "browser-startup");
}
}
return data;
});
},
startVerifiedCheck(data) {
log.debug("startVerifiedCheck", data && data.verified);
if (logPII) {
log.debug("startVerifiedCheck with user data", data);
}
// Get us to the verified state, then get the keys. This returns a promise
// that will fire when we are completely ready.
//
// Login is truly complete once keys have been fetched, so once getKeys()
// obtains and stores kSync kXCS kExtSync and kExtKbHash, it will fire the
// onverified observer notification.
// The callers of startVerifiedCheck never consume a returned promise (ie,
// this is simply kicking off a background fetch) so we must add a rejection
// handler to avoid runtime warnings about the rejection not being handled.
this.whenVerified(data).then(
() => this.getKeys(),
err => log.info("startVerifiedCheck promise was rejected: " + err)
);
},
whenVerified(data) {
let currentState = this.currentAccountState;
if (data.verified) {
log.debug("already verified");
return currentState.resolve(data);
}
if (!currentState.whenVerifiedDeferred) {
log.debug("whenVerified promise starts polling for verified email");
this.startPollEmailStatus(currentState, data.sessionToken, "start");
}
return currentState.whenVerifiedDeferred.promise.then(
result => currentState.resolve(result)
);
},
async notifyObservers(topic, data) {
for (let f of this.observerPreloads) {
try {
await f();
} catch (O_o) {}
}
log.debug("Notifying observers of " + topic);
Services.obs.notifyObservers(null, topic, data);
},
startPollEmailStatus(currentState, sessionToken, why) {
log.debug("entering startPollEmailStatus: " + why);
// If we were already polling, stop and start again. This could happen
// if the user requested the verification email to be resent while we
// were already polling for receipt of an earlier email.
if (this.currentTimer) {
log.debug("startPollEmailStatus starting while existing timer is running");
clearTimeout(this.currentTimer);
this.currentTimer = null;
}
this.pollStartDate = Date.now();
if (!currentState.whenVerifiedDeferred) {
currentState.whenVerifiedDeferred = PromiseUtils.defer();
// This deferred might not end up with any handlers (eg, if sync
// is yet to start up.) This might cause "A promise chain failed to
// handle a rejection" messages, so add an error handler directly
// on the promise to log the error.
currentState.whenVerifiedDeferred.promise.catch(err => {
log.info("the wait for user verification was stopped: " + err);
});
}
return this.pollEmailStatus(currentState, sessionToken, why);
},
// We return a promise for testing only. Other callers can ignore this,
// since verification polling continues in the background.
async pollEmailStatus(currentState, sessionToken, why) {
log.debug("entering pollEmailStatus: " + why);
let nextPollMs;
try {
const response = await this.checkEmailStatus(sessionToken, { reason: why });
log.debug("checkEmailStatus -> " + JSON.stringify(response));
if (response && response.verified) {
await this.onPollEmailSuccess(currentState);
return;
}
} catch (error) {
if (error && error.code && error.code == 401) {
let error = new Error("Verification status check failed");
this._rejectWhenVerified(currentState, error);
return;
}
if (error && error.retryAfter) {
// If the server told us to back off, back off the requested amount.
nextPollMs = (error.retryAfter + 3) * 1000;
log.warn(`the server rejected our email status check and told us to try again in ${nextPollMs}ms`);
} else {
log.error(`checkEmailStatus failed to poll`, error);
}
}
if (why == "push") {
return;
}
let pollDuration = Date.now() - this.pollStartDate;
// Polling session expired.
if (pollDuration >= this.POLL_SESSION) {
if (currentState.whenVerifiedDeferred) {
let error = new Error("User email verification timed out.");
this._rejectWhenVerified(currentState, error);
}
log.debug("polling session exceeded, giving up");
return;
}
// Poll email status again after a short delay.
if (nextPollMs === undefined) {
let currentMinute = Math.ceil(pollDuration / 60000);
nextPollMs = (why == "start" && currentMinute < this.VERIFICATION_POLL_START_SLOWDOWN_THRESHOLD) ?
this.VERIFICATION_POLL_TIMEOUT_INITIAL :
this.VERIFICATION_POLL_TIMEOUT_SUBSEQUENT;
}
this._scheduleNextPollEmailStatus(currentState, sessionToken, nextPollMs, why);
},
// Easy-to-mock testable method
_scheduleNextPollEmailStatus(currentState, sessionToken, nextPollMs, why) {
log.debug("polling with timeout = " + nextPollMs);
this.currentTimer = setTimeout(() => {
this.pollEmailStatus(currentState, sessionToken, why);
}, nextPollMs);
},
async onPollEmailSuccess(currentState) {
try {
await currentState.updateUserAccountData({ verified: true });
const accountData = await currentState.getUserAccountData();
// Now that the user is verified, we can proceed to fetch keys
if (currentState.whenVerifiedDeferred) {
currentState.whenVerifiedDeferred.resolve(accountData);
delete currentState.whenVerifiedDeferred;
}
// Tell FxAccountsManager to clear its cache
await this.notifyObservers(ON_FXA_UPDATE_NOTIFICATION, ONVERIFIED_NOTIFICATION);
} catch (e) {
log.error(e);
}
},
_rejectWhenVerified(currentState, error) {
currentState.whenVerifiedDeferred.reject(error);
delete currentState.whenVerifiedDeferred;
},
/**
* Get an OAuth token for the user
*
* @param options
* {
* 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>
* The promise resolves the oauth token as a string or rejects with
* an error object ({error: ERROR, details: {}}) of the following:
* INVALID_PARAMETER
* NO_ACCOUNT
* UNVERIFIED_ACCOUNT
* NETWORK_ERROR
* AUTH_ERROR
* UNKNOWN_ERROR
*/
async getOAuthToken(options = {}) {
log.debug("getOAuthToken enter");
let scope = options.scope;
if (typeof scope === "string") {
scope = [scope];
}
if (!scope || !scope.length) {
throw this._error(ERROR_INVALID_PARAMETER, "Missing or invalid 'scope' option");
}
await 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: defaultURL,
client_id: FX_OAUTH_CLIENT_ID
});
} catch (e) {
throw this._error(ERROR_INVALID_PARAMETER, e);
}
}
let oAuthURL = client.serverURL.href;
try {
log.debug("getOAuthToken fetching new token from", oAuthURL);
let assertion = await this.getAssertion(oAuthURL);
let result = await client.getTokenFromAssertion(assertion, scopeString);
let token = result.access_token;
// If we got one, cache it.
if (token) {
let entry = {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.
*/
async removeCachedOAuthToken(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);
});
}
},
async _getVerifiedAccountOrReject() {
let data = await this.currentAccountState.getUserAccountData();
if (!data) {
// No signed-in user
throw this._error(ERROR_NO_ACCOUNT);
}
if (!this.isUserEmailVerified(data)) {
// Signed-in user has not verified email
throw this._error(ERROR_UNVERIFIED_ACCOUNT);
}
return data;
},
/*
* Coerce an error into one of the general error cases:
* NETWORK_ERROR
* AUTH_ERROR
* UNKNOWN_ERROR
*
* These errors will pass through:
* INVALID_PARAMETER
* NO_ACCOUNT
* UNVERIFIED_ACCOUNT
*/
_errorToErrorClass(aError) {
if (aError.errno) {
let error = SERVER_ERRNO_TO_ERROR[aError.errno];
return this._error(ERROR_TO_GENERAL_ERROR_CLASS[error] || ERROR_UNKNOWN, aError);
} else if (aError.message &&
(aError.message === "INVALID_PARAMETER" ||
aError.message === "NO_ACCOUNT" ||
aError.message === "UNVERIFIED_ACCOUNT" ||
aError.message === "AUTH_ERROR")) {
return aError;
}
return this._error(ERROR_UNKNOWN, aError);
},
_error(aError, aDetails) {
log.error("FxA rejecting with error ${aError}, details: ${aDetails}", {aError, aDetails});
let reason = new Error(aError);
if (aDetails) {
reason.details = aDetails;
}
return reason;
},
/**
* Get the user's account and profile data if it is locally cached. If
* not cached it will return null, but cause the profile data to be fetched
* in the background, after which a ON_PROFILE_CHANGE_NOTIFICATION
* observer notification will be sent, at which time this can be called
* again to obtain the most recent profile info.
*
* @return Promise.<object | Error>
* The promise resolves to an accountData object with extra profile
* information such as profileImageUrl, or rejects with
* an error object ({error: ERROR, details: {}}) of the following:
* INVALID_PARAMETER
* NO_ACCOUNT
* UNVERIFIED_ACCOUNT
* NETWORK_ERROR
* AUTH_ERROR
* UNKNOWN_ERROR
*/
getSignedInUserProfile() {
let currentState = this.currentAccountState;
return this.profile.getProfile().then(
profileData => {
let profile = Cu.cloneInto(profileData, {});
return currentState.resolve(profile);
},
error => {
log.error("Could not retrieve profile data", error);
return currentState.reject(error);
}
).catch(err => Promise.reject(this._errorToErrorClass(err)));
},
// Attempt to update the auth server with whatever device details are stored
// in the account data. Returns a promise that always resolves, never rejects.
// If the promise resolves to a value, that value is the device id.
updateDeviceRegistration() {
return this.getSignedInUser().then(signedInUser => {
if (signedInUser) {
return this._registerOrUpdateDevice(signedInUser);
}
return null;
}).catch(error => this._logErrorAndResetDeviceRegistrationVersion(error));
},
// Delete the Push Subscription and the device registration on the auth server.
// Returns a promise that always resolves, never rejects.
async deleteDeviceRegistration(sessionToken, deviceId) {
try {
// Allow tests to skip device registration because it makes remote requests to the auth server.
if (Services.prefs.getBoolPref("identity.fxaccounts.skipDeviceRegistration")) {
return Promise.resolve();
}
} catch (ignore) {}
try {
await this.fxaPushService.unsubscribe();
if (sessionToken && deviceId) {
await this.fxAccountsClient.signOutAndDestroyDevice(sessionToken, deviceId);
}
await this.currentAccountState.updateUserAccountData({
deviceId: null,
deviceRegistrationVersion: null
});
} catch (err) {
log.error("Could not delete the device registration", err);
}
return Promise.resolve();
},
async handleDeviceDisconnection(deviceId) {
const accountData = await this.currentAccountState.getUserAccountData();
const localDeviceId = accountData ? accountData.deviceId : null;
const isLocalDevice = (deviceId == localDeviceId);
if (isLocalDevice) {
this.signOut(true);
}
const data = JSON.stringify({ isLocalDevice });
await this.notifyObservers(ON_DEVICE_DISCONNECTED_NOTIFICATION, data);
return null;
},
handleEmailUpdated(newEmail) {
Services.prefs.setStringPref(PREF_LAST_FXA_USER, CryptoUtils.sha256Base64(newEmail));
return this.currentAccountState.updateUserAccountData({ email: newEmail });
},
async handleAccountDestroyed(uid) {
const accountData = await this.currentAccountState.getUserAccountData();
const localUid = accountData ? accountData.uid : null;
if (!localUid) {
log.info(`Account destroyed push notification received, but we're already logged-out`);
return null;
}
if (uid == localUid) {
const data = JSON.stringify({ isLocalDevice: true });
await this.notifyObservers(ON_DEVICE_DISCONNECTED_NOTIFICATION, data);
return this.signOut(true);
}
log.info(
`The destroyed account uid doesn't match with the local uid. ` +
`Local: ${localUid}, account uid destroyed: ${uid}`);
return null;
},
/**
* Delete all the cached persisted credentials we store for FxA.
*
* @return Promise resolves when the user data has been persisted
*/
resetCredentials() {
// Delete all fields except those required for the user to
// reauthenticate.
let updateData = {};
let clearField = field => {
if (!FXA_PWDMGR_REAUTH_WHITELIST.has(field)) {
updateData[field] = null;
}
};
FXA_PWDMGR_PLAINTEXT_FIELDS.forEach(clearField);
FXA_PWDMGR_SECURE_FIELDS.forEach(clearField);
FXA_PWDMGR_MEMORY_FIELDS.forEach(clearField);
let currentState = this.currentAccountState;
return currentState.updateUserAccountData(updateData);
},
getProfileCache() {
return this.currentAccountState.getUserAccountData(["profileCache"])
.then(data => data ? data.profileCache : null);
},
setProfileCache(profileCache) {
return this.currentAccountState.updateUserAccountData({
profileCache
});
},
// If you change what we send to the FxA servers during device registration,
// you'll have to bump the DEVICE_REGISTRATION_VERSION number to force older
// devices to re-register when Firefox updates
_registerOrUpdateDevice(signedInUser) {
try {
// Allow tests to skip device registration because:
// 1. It makes remote requests to the auth server.
// 2. _getDeviceName does not work from xpcshell.
// 3. The B2G tests fail when attempting to import services-sync/util.js.
if (Services.prefs.getBoolPref("identity.fxaccounts.skipDeviceRegistration")) {
return Promise.resolve();
}
} catch (ignore) {}
if (!signedInUser.sessionToken) {
return Promise.reject(new Error(
"_registerOrUpdateDevice called without a session token"));
}
return this.fxaPushService.registerPushEndpoint().then(subscription => {
const deviceName = this._getDeviceName();
let deviceOptions = {};
// if we were able to obtain a subscription
if (subscription && subscription.endpoint) {
deviceOptions.pushCallback = subscription.endpoint;
let publicKey = subscription.getKey("p256dh");
let authKey = subscription.getKey("auth");
if (publicKey && authKey) {
deviceOptions.pushPublicKey = urlsafeBase64Encode(publicKey);
deviceOptions.pushAuthKey = urlsafeBase64Encode(authKey);
}
}
if (signedInUser.deviceId) {
log.debug("updating existing device details");
return this.fxAccountsClient.updateDevice(
signedInUser.sessionToken, signedInUser.deviceId, deviceName, deviceOptions);
}
log.debug("registering new device details");
return this.fxAccountsClient.registerDevice(
signedInUser.sessionToken, deviceName, this._getDeviceType(), deviceOptions);
}).then(device =>
this.currentAccountState.updateUserAccountData({
deviceId: device.id,
deviceRegistrationVersion: this.DEVICE_REGISTRATION_VERSION
}).then(() => device.id)
).catch(error => this._handleDeviceError(error, signedInUser.sessionToken));
},
_getDeviceName() {
return Utils.getDeviceName();
},
_getDeviceType() {
return Utils.getDeviceType();
},
_handleDeviceError(error, sessionToken) {
return Promise.resolve().then(() => {
if (error.code === 400) {
if (error.errno === ERRNO_UNKNOWN_DEVICE) {
return this._recoverFromUnknownDevice();
}
if (error.errno === ERRNO_DEVICE_SESSION_CONFLICT) {
return this._recoverFromDeviceSessionConflict(error, sessionToken);
}
}
// `_handleTokenError` re-throws the error.
return this._handleTokenError(error);
}).catch(error =>
this._logErrorAndResetDeviceRegistrationVersion(error)
).catch(() => {});
},
_recoverFromUnknownDevice() {
// FxA did not recognise the device id. Handle it by clearing the device
// id on the account data. At next sync or next sign-in, registration is
// retried and should succeed.
log.warn("unknown device id, clearing the local device data");
return this.currentAccountState.updateUserAccountData({ deviceId: null })
.catch(error => this._logErrorAndResetDeviceRegistrationVersion(error));
},
_recoverFromDeviceSessionConflict(error, sessionToken) {
// FxA has already associated this session with a different device id.
// Perhaps we were beaten in a race to register. Handle the conflict:
// 1. Fetch the list of devices for the current user from FxA.
// 2. Look for ourselves in the list.
// 3. If we find a match, set the correct device id and device registration
// version on the account data and return the correct device id. At next
// sync or next sign-in, registration is retried and should succeed.
// 4. If we don't find a match, log the original error.
log.warn("device session conflict, attempting to ascertain the correct device id");
return this.fxAccountsClient.getDeviceList(sessionToken)
.then(devices => {
const matchingDevices = devices.filter(device => device.isCurrentDevice);
const length = matchingDevices.length;
if (length === 1) {
const deviceId = matchingDevices[0].id;
return this.currentAccountState.updateUserAccountData({
deviceId,
deviceRegistrationVersion: null
}).then(() => deviceId);
}
if (length > 1) {
log.error("insane server state, " + length + " devices for this session");
}
return this._logErrorAndResetDeviceRegistrationVersion(error);
}).catch(secondError => {
log.error("failed to recover from device-session conflict", secondError);
this._logErrorAndResetDeviceRegistrationVersion(error);
});
},
_logErrorAndResetDeviceRegistrationVersion(error) {
// Device registration should never cause other operations to fail.
// If we've reached this point, just log the error and reset the device
// registration version on the account data. At next sync or next sign-in,
// registration will be retried.
log.error("device registration failed", error);
return this.currentAccountState.updateUserAccountData({
deviceRegistrationVersion: null
}).catch(secondError => {
log.error(
"failed to reset the device registration version, device registration won't be retried",
secondError);
}).then(() => {});
},
_handleTokenError(err) {
if (!err || err.code != 401 || err.errno != ERRNO_INVALID_AUTH_TOKEN) {
throw err;
}
log.warn("recovering from invalid token error", err);
return this.accountStatus().then(exists => {
if (!exists) {
// Delete all local account data. Since the account no longer
// exists, we can skip the remote calls.
log.info("token invalidated because the account no longer exists");
return this.signOut(true);
}
log.info("clearing credentials to handle invalid token error");
return this.resetCredentials();
}).then(() => Promise.reject(err));
},
};
// A getter for the instance to export
XPCOMUtils.defineLazyGetter(this, "fxAccounts", function() {
let a = new FxAccounts();
// XXX Bug 947061 - We need a strategy for resuming email verification after
// browser restart
a.loadAndPoll();
return a;
});