From 875032f949d87434c1be87cfb6e21725de61dac5 Mon Sep 17 00:00:00 2001 From: Sam Penrose Date: Wed, 2 Oct 2013 23:48:08 +0200 Subject: [PATCH] Bug 911378 - A BrowserID/Hawk based IdentityManager for Sync. r=rnewman --- services/sync/Makefile.in | 1 + services/sync/modules/browserid_identity.js | 170 ++++++++++++++++++ services/sync/services-sync.js | 2 + .../tests/unit/test_browserid_identity.js | 136 ++++++++++++++ services/sync/tests/unit/test_load_modules.js | 2 +- services/sync/tests/unit/xpcshell.ini | 1 + 6 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 services/sync/modules/browserid_identity.js create mode 100644 services/sync/tests/unit/test_browserid_identity.js diff --git a/services/sync/Makefile.in b/services/sync/Makefile.in index 555aec3e3904..b8e43211923d 100644 --- a/services/sync/Makefile.in +++ b/services/sync/Makefile.in @@ -18,6 +18,7 @@ PP_TARGETS += SYNC_PP sync_modules := \ addonsreconciler.js \ addonutils.js \ + browserid_identity.js \ engines.js \ identity.js \ jpakeclient.js \ diff --git a/services/sync/modules/browserid_identity.js b/services/sync/modules/browserid_identity.js new file mode 100644 index 000000000000..bba15a23df6c --- /dev/null +++ b/services/sync/modules/browserid_identity.js @@ -0,0 +1,170 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["BrowserIDManager"]; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://services-common/async.js"); +Cu.import("resource://services-common/log4moz.js"); +Cu.import("resource://services-common/tokenserverclient.js"); +Cu.import("resource://services-crypto/utils.js"); +Cu.import("resource://services-sync/identity.js"); +Cu.import("resource://services-sync/util.js"); + +/** + * Fetch a token for the sync storage server by passing a BrowserID assertion + * from FxAccounts() to TokenServerClient, then wrap the token in in a Hawk + * header so that SyncStorageRequest can connect. + */ + +this.BrowserIDManager = function BrowserIDManager(fxaService, tokenServerClient) { + this._fxaService = fxaService; + this._tokenServerClient = tokenServerClient; + this._log = Log4Moz.repository.getLogger("Sync.Identity"); + this._log.Level = Log4Moz.Level[Svc.Prefs.get("log.logger.identity")]; + +}; + +this.BrowserIDManager.prototype = { + __proto__: IdentityManager.prototype, + + _fxaService: null, + _tokenServerClient: null, + // https://docs.services.mozilla.com/token/apis.html + _token: null, + + _clearUserState: function() { + this.account = null; + this._token = null; + }, + + /** + * Unify string munging in account setter and testers (e.g. hasValidToken). + */ + _normalizeAccountValue: function(value) { + return value.toLowerCase(); + }, + + /** + * Provide override point for testing token expiration. + */ + _now: function() { + return Date.now(); + }, + + /** + * Do we have a non-null, not yet expired token whose email field + * matches (when normalized) our account field? + * + * If the calling function receives false from hasValidToken, it is + * responsible for calling _clearUserData(). + */ + hasValidToken: function() { + if (!this._token) { + return false; + } + if (this._token.expiration < this._now()) { + return false; + } + let signedInUser = this._getSignedInUser(); + if (!signedInUser) { + return false; + } + // Does the signed in user match the user we retrieved the token for? + if (this._normalizeAccountValue(signedInUser.email) !== this.account) { + return false; + } + return true; + }, + + /** + * Wrap and synchronize FxAccounts.getSignedInUser(). + * + * @return credentials per wrapped. + */ + _getSignedInUser: function() { + let userBlob; + let cb = Async.makeSpinningCallback(); + + this._fxaService.getSignedInUser().then(function (result) { + cb(null, result); + }, + function (err) { + cb(err); + }); + + try { + userBlob = cb.wait(); + } catch (err) { + this._log.info("FxAccounts.getSignedInUser() failed with: " + err); + return null; + } + return userBlob; + }, + + _fetchTokenForUser: function(user) { + let token; + let cb = Async.makeSpinningCallback(); + let tokenServerURI = Svc.Prefs.get("services.sync.tokenServerURI"); + + try { + this._tokenServerClient.getTokenFromBrowserIDAssertion( + tokenServerURI, user.assertion, cb); + token = cb.wait(); + } catch (err) { + this._log.info("TokenServerClient.getTokenFromBrowserIDAssertion() failed with: " + err.api_endpoint); + return null; + } + + token.expiration = this._now() + (token.duration * 1000); + return token; + }, + + getResourceAuthenticator: function() { + return this._getAuthenticationHeader.bind(this); + }, + + /** + * @return a Hawk HTTP Authorization Header, lightly wrapped, for the .uri + * of a RESTRequest or AsyncResponse object. + */ + _getAuthenticationHeader: function(httpObject, method) { + if (!this.hasValidToken()) { + this._clearUserState(); + let user = this._getSignedInUser(); + if (!user) { + return null; + } + this._token = this._fetchTokenForUser(user); + if (!this._token) { + return null; + } + this.account = this._normalizeAccountValue(user.email); + } + let credentials = {algorithm: "sha256", + id: this.username, + key: this._token, + }; + method = method || httpObject.method; + let headerValue = CryptoUtils.computeHAWK(httpObject.uri, method, + {credentials: credentials}); + return {headers: {authorization: headerValue.field}}; + }, + + getRequestAuthenticator: function() { + return this._addAuthenticationHeader.bind(this); + }, + + _addAuthenticationHeader: function(request, method) { + let header = this._getAuthenticationHeader(request, method); + if (!header) { + return null; + } + request.setHeader("authorization", header.headers.authorization); + return request; + } +}; diff --git a/services/sync/services-sync.js b/services/sync/services-sync.js index ff931258f15c..cd176336e0ad 100644 --- a/services/sync/services-sync.js +++ b/services/sync/services-sync.js @@ -71,3 +71,5 @@ pref("services.sync.log.logger.engine.addons", "Debug"); pref("services.sync.log.logger.engine.apps", "Debug"); pref("services.sync.log.logger.userapi", "Debug"); pref("services.sync.log.cryptoDebug", false); + +pref("services.sync.tokenServerURI", "http://auth.oldsync.dev.lcip.org/1.0/sync/1.1"); diff --git a/services/sync/tests/unit/test_browserid_identity.js b/services/sync/tests/unit/test_browserid_identity.js new file mode 100644 index 000000000000..504a1eaab8eb --- /dev/null +++ b/services/sync/tests/unit/test_browserid_identity.js @@ -0,0 +1,136 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/FxAccounts.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://services-sync/browserid_identity.js"); +Cu.import("resource://services-sync/rest.js"); +Cu.import("resource://services-sync/util.js"); + +let mockUser = {assertion: 'assertion', + email: 'email', + kA: 'kA', + kB: 'kB', + sessionToken: 'sessionToken', + uid: 'user_uid', + }; + +let _MockFXA = function(blob) { + this.user = blob; +}; +_MockFXA.prototype = { + __proto__: FxAccounts.prototype, + getSignedInUser: function getSignedInUser() { + let deferred = Promise.defer(); + deferred.resolve(this.user); + return deferred.promise; + }, +}; +let mockFXA = new _MockFXA(mockUser); + +let mockToken = { + api_endpoint: Svc.Prefs.get("services.sync.tokenServerURI"), + duration: 300, + id: "id", + key: "key", + uid: "token_uid", +}; +let mockTSC = { // TokenServerClient + getTokenFromBrowserIDAssertion: function(uri, assertion, cb) { + cb(null, mockToken); + }, +}; + +let browseridManager = new BrowserIDManager(mockFXA, mockTSC); + +function run_test() { + initTestLogging("Trace"); + Log4Moz.repository.getLogger("Sync.Identity").level = Log4Moz.Level.Trace; + run_next_test(); +}; + +add_test(function test_initial_state() { + _("Verify initial state"); + do_check_false(!!browseridManager._token); + do_check_false(browseridManager.hasValidToken()); + do_check_false(!!browseridManager.account); + run_next_test(); + } +); + +add_test(function test_getResourceAuthenticator() { + _("BrowserIDManager supplies a Resource Authenticator callback which returns a Hawk header."); + let authenticator = browseridManager.getResourceAuthenticator(); + do_check_true(!!authenticator); + let req = {uri: CommonUtils.makeURI( + "https://example.net/somewhere/over/the/rainbow"), + method: 'GET'}; + let output = authenticator(req, 'GET'); + do_check_true('headers' in output); + do_check_true('authorization' in output.headers); + do_check_true(output.headers.authorization.startsWith('Hawk')); + _("Expected internal state after successful call."); + do_check_eq(browseridManager._token.uid, mockToken.uid); + do_check_eq(browseridManager.account, browseridManager._normalizeAccountValue(mockUser.email)); + run_next_test(); + } +); + +add_test(function test_getRequestAuthenticator() { + _("BrowserIDManager supplies a Request Authenticator callback which sets a Hawk header on a request object."); + let request = new SyncStorageRequest( + "https://example.net/somewhere/over/the/rainbow"); + let authenticator = browseridManager.getRequestAuthenticator(); + do_check_true(!!authenticator); + let output = authenticator(request, 'GET'); + do_check_eq(request.uri, output.uri); + do_check_true(output._headers.authorization.startsWith('Hawk')); + do_check_true(output._headers.authorization.contains('nonce')); + do_check_true(browseridManager.hasValidToken()); + run_next_test(); + } +); + +add_test(function test_tokenExpiration() { + _("BrowserIDManager notices token expiration:"); + let bimExp = new BrowserIDManager(mockFXA, mockTSC); + + let authenticator = bimExp.getResourceAuthenticator(); + do_check_true(!!authenticator); + let req = {uri: CommonUtils.makeURI( + "https://example.net/somewhere/over/the/rainbow"), + method: 'GET'}; + authenticator(req, 'GET'); + + // Mock the clock. + _("Forcing the token to expire ..."); + Object.defineProperty(bimExp, "_now", { + value: function customNow() { + return (Date.now() + 3000001); + }, + writable: true, + }); + do_check_true(bimExp._token.expiration < bimExp._now()); + _("... means BrowserIDManager knows to re-fetch it on the next call."); + do_check_false(bimExp.hasValidToken()); + run_next_test(); + } +); + +add_test(function test_userChangeAndLogOut() { + _("BrowserIDManager notices when the FxAccounts.getSignedInUser().email changes."); + let mockFXA2 = new _MockFXA(mockUser); + let bidUser = new BrowserIDManager(mockFXA2, mockTSC); + let request = new SyncStorageRequest( + "https://example.net/somewhere/over/the/rainbow"); + let authenticator = bidUser.getRequestAuthenticator(); + do_check_true(!!authenticator); + let output = authenticator(request, 'GET'); + do_check_true(!!output); + do_check_eq(bidUser.account, mockUser.email); + do_check_true(bidUser.hasValidToken()); + mockUser.email = "something@new"; + do_check_false(bidUser.hasValidToken()); + run_next_test(); + } +); diff --git a/services/sync/tests/unit/test_load_modules.js b/services/sync/tests/unit/test_load_modules.js index 5169484687df..bcb7f6bddefb 100644 --- a/services/sync/tests/unit/test_load_modules.js +++ b/services/sync/tests/unit/test_load_modules.js @@ -4,6 +4,7 @@ const modules = [ "addonutils.js", "addonsreconciler.js", + "browserid_identity.js", "constants.js", "engines/addons.js", "engines/bookmarks.js", @@ -50,4 +51,3 @@ function run_test() { Cu.import(res, {}); } } - diff --git a/services/sync/tests/unit/xpcshell.ini b/services/sync/tests/unit/xpcshell.ini index ed6729cd5294..c72095a37aed 100644 --- a/services/sync/tests/unit/xpcshell.ini +++ b/services/sync/tests/unit/xpcshell.ini @@ -50,6 +50,7 @@ skip-if = os == "win" || os == "android" [test_syncstoragerequest.js] # Generic Sync types. +[test_browserid_identity.js] [test_collection_inc_get.js] [test_collections_recovery.js] [test_identity_manager.js]