Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
Bug 1227527 - Implement basic FxA device registration. r=markh
  • Loading branch information
philbooth committed Jan 13, 2016
1 parent d0fd610 commit 8a56463
Show file tree
Hide file tree
Showing 23 changed files with 1,181 additions and 77 deletions.
3 changes: 3 additions & 0 deletions b2g/app/b2g.js
Expand Up @@ -1064,6 +1064,9 @@ pref("services.mobileid.server.uri", "https://msisdn.services.mozilla.com");
pref("identity.fxaccounts.remote.oauth.uri", "https://oauth.accounts.firefox.com/v1");
pref("identity.fxaccounts.remote.profile.uri", "https://profile.accounts.firefox.com/v1");

// Disable Firefox Accounts device registration until bug 1238895 is fixed.
pref("identity.fxaccounts.skipDeviceRegistration", true);

// Enable mapped array buffer.
#ifndef XP_WIN
pref("dom.mapped_arraybuffer.enabled", true);
Expand Down
10 changes: 10 additions & 0 deletions b2g/components/test/unit/test_fxaccounts.js
Expand Up @@ -24,6 +24,7 @@ const ORIGINAL_SENDCUSTOM = SystemAppProxy._sendCustomEvent;
do_register_cleanup(function() {
Services.prefs.setCharPref("identity.fxaccounts.auth.uri", ORIGINAL_AUTH_URI);
SystemAppProxy._sendCustomEvent = ORIGINAL_SENDCUSTOM;
Services.prefs.clearUserPref("identity.fxaccounts.skipDeviceRegistration");
});

// Make profile available so that fxaccounts can store user data
Expand All @@ -39,6 +40,9 @@ function run_test() {
}

add_task(function test_overall() {
// FxA device registration throws from this context
Services.prefs.setBoolPref("identity.fxaccounts.skipDeviceRegistration", true);

do_check_neq(FxAccountsMgmtService, null);
});

Expand Down Expand Up @@ -105,6 +109,9 @@ add_test(function test_invalidEmailCase_signIn() {
// Point the FxAccountsClient's hawk rest request client to the mock server
Services.prefs.setCharPref("identity.fxaccounts.auth.uri", server.baseURI);

// FxA device registration throws from this context
Services.prefs.setBoolPref("identity.fxaccounts.skipDeviceRegistration", true);

// Receive a mozFxAccountsChromeEvent message
function onMessage(subject, topic, data) {
let message = subject.wrappedJSObject;
Expand Down Expand Up @@ -164,6 +171,9 @@ add_test(function test_invalidEmailCase_signIn() {
add_test(function testHandleGetAssertionError_defaultCase() {
do_test_pending();

// FxA device registration throws from this context
Services.prefs.setBoolPref("identity.fxaccounts.skipDeviceRegistration", true);

FxAccountsManager.getAssertion(null).then(
success => {
// getAssertion should throw with invalid audience
Expand Down
184 changes: 174 additions & 10 deletions services/fxaccounts/FxAccounts.jsm
Expand Up @@ -30,13 +30,17 @@ XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsOAuthGrantClient",
XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsProfile",
"resource://gre/modules/FxAccountsProfile.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "Utils",
"resource://services-sync/util.js");

// All properties exposed by the public FxAccounts API.
var publicProperties = [
"accountStatus",
"getAccountsClient",
"getAccountsSignInURI",
"getAccountsSignUpURI",
"getAssertion",
"getDeviceId",
"getKeys",
"getSignedInUser",
"getOAuthToken",
Expand All @@ -51,6 +55,7 @@ var publicProperties = [
"resendVerificationEmail",
"setSignedInUser",
"signOut",
"updateDeviceRegistration",
"whenVerified"
];

Expand Down Expand Up @@ -490,7 +495,9 @@ FxAccountsInternal.prototype = {
// 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 ;)
return currentAccountState.promiseInitialized.then(() => {
return currentAccountState.promiseInitialized.then(() =>
this.updateDeviceRegistration()
).then(() => {
Services.telemetry.getHistogramById("FXA_CONFIGURED").add(1);
this.notifyObservers(ONLOGIN_NOTIFICATION);
if (!this.isUserEmailVerified(credentials)) {
Expand Down Expand Up @@ -539,6 +546,26 @@ FxAccountsInternal.prototype = {
}).then(result => currentState.resolve(result));
},

getDeviceId() {
return this.currentAccountState.getUserAccountData()
.then(data => {
if (data) {
if (data.isDeviceStale || !data.deviceId) {
// A previous device registration attempt failed or there is no
// device id. 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;
});
},

/**
* Resend the verification email fot the currently signed-in user.
*
Expand Down Expand Up @@ -605,10 +632,15 @@ FxAccountsInternal.prototype = {
let currentState = this.currentAccountState;
let sessionToken;
let tokensToRevoke;
let deviceId;
return currentState.getUserAccountData().then(data => {
// Save the session token for use in the call to signOut below.
sessionToken = data && data.sessionToken;
tokensToRevoke = data && data.oauthTokens;
// 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
Expand All @@ -620,7 +652,7 @@ FxAccountsInternal.prototype = {
// 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.
return this._signOutServer(sessionToken);
return this._signOutServer(sessionToken, deviceId);
}).catch(err => {
log.error("Error during remote sign out of Firefox Accounts", err);
}).then(() => {
Expand Down Expand Up @@ -652,11 +684,22 @@ FxAccountsInternal.prototype = {
});
},

_signOutServer: function signOutServer(sessionToken) {
// For now we assume the service being logged out from is Sync - we might
// need to revisit this when this FxA code is used in a context that
// isn't Sync.
return this.fxAccountsClient.signOut(sessionToken, {service: "sync"});
_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 and session");
return this.fxAccountsClient.signOutAndDestroyDevice(sessionToken, deviceId, options)
.then(() => this.currentAccountState.updateUserAccountData({ deviceId: null }));
}

log.debug("destroying session");
return this.fxAccountsClient.signOut(sessionToken, options);
},

/**
Expand Down Expand Up @@ -1334,6 +1377,127 @@ FxAccountsInternal.prototype = {
}
).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);
}
}).catch(error => this._logErrorAndSetStaleDeviceFlag(error));
},

_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) {}

return Promise.resolve().then(() => {
const deviceName = this._getDeviceName();

if (signedInUser.deviceId) {
log.debug("updating existing device details");
return this.fxAccountsClient.updateDevice(
signedInUser.sessionToken, signedInUser.deviceId, deviceName);
}

log.debug("registering new device details");
return this.fxAccountsClient.registerDevice(
signedInUser.sessionToken, deviceName, this._getDeviceType());
}).then(device =>
this.currentAccountState.updateUserAccountData({
deviceId: device.id,
isDeviceStale: null
}).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);
}
}

return this._logErrorAndSetStaleDeviceFlag(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._logErrorAndSetStaleDeviceFlag(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 the stale device
// flag 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,
isDeviceStale: true
}).then(() => deviceId);
}
if (length > 1) {
log.error("insane server state, " + length + " devices for this session");
}
return this._logErrorAndSetStaleDeviceFlag(error);
}).catch(secondError => {
log.error("failed to recover from device-session conflict", secondError);
this._logErrorAndSetStaleDeviceFlag(error)
});
},

_logErrorAndSetStaleDeviceFlag(error) {
// Device registration should never cause other operations to fail.
// If we've reached this point, just log the error and set the stale
// device flag 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({
isDeviceStale: true
}).catch(secondError => {
log.error(
"failed to set stale device flag, device registration won't be retried",
secondError);
}).then(() => {});
}
};


Expand Down

0 comments on commit 8a56463

Please sign in to comment.