Bug 745345: BrowserID support for Apps in the Cloud; r=khuey, r=gps

This commit is contained in:
Anant Narayanan 2012-06-02 22:08:54 -07:00
parent 4607dba17b
commit 8e0c38bec9
7 changed files with 595 additions and 1 deletions

View File

@ -0,0 +1,458 @@
/* 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";
const EXPORTED_SYMBOLS = ["BrowserID"];
const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://services-common/utils.js");
Cu.import("resource://services-common/log4moz.js");
Cu.import("resource://services-common/preferences.js");
const PREFS = new Preferences("services.aitc.browserid.");
/**
* This implementation will be replaced with native crypto and assertion
* generation goodness. See bug 753238.
*/
function BrowserIDService() {
this._log = Log4Moz.repository.getLogger("Services.BrowserID");
this._log.level = Log4Moz.Level[PREFS.get("log")];
}
BrowserIDService.prototype = {
/**
* Getter that returns the freshest value for ID_URI.
*/
get ID_URI() {
return PREFS.get("url");
},
/**
* Obtain a BrowserID assertion with the specified characteristics.
*
* @param cb
* (Function) Callback to be called with (err, assertion) where 'err'
* can be an Error or NULL, and 'assertion' can be NULL or a valid
* BrowserID assertion. If no callback is provided, an exception is
* thrown.
*
* @param options
* (Object) An object that may contain the following properties:
*
* "requiredEmail" : An email for which the assertion is to be
* issued. If one could not be obtained, the call
* will fail. If this property is not specified,
* the default email as set by the user will be
* chosen. If both this property and "sameEmailAs"
* are set, an exception will be thrown.
*
* "sameEmailAs" : If set, instructs the function to issue an
* assertion for the same email that was provided
* to the domain specified by this value. If this
* information could not be obtained, the call
* will fail. If both this property and
* "requiredEmail" are set, an exception will be
* thrown.
*
* "audience" : The audience for which the assertion is to be
* issued. If this property is not set an exception
* will be thrown.
*
* Any properties not listed above will be ignored.
*
* (This function could use some love in terms of what arguments it accepts.
* See bug 746401.)
*/
getAssertion: function getAssertion(cb, options) {
if (!cb) {
throw new Error("getAssertion called without a callback");
}
if (!options) {
throw new Error("getAssertion called without any options");
}
if (!options.audience) {
throw new Error("getAssertion called without an audience");
}
if (options.sameEmailAs && options.requiredEmail) {
throw new Error(
"getAssertion sameEmailAs and requiredEmail are mutually exclusive"
);
}
new Sandbox(this._getEmails.bind(this, cb, options), this.ID_URI);
},
/**
* Obtain a BrowserID assertion by asking the user to login and select an
* email address.
*
* @param cb
* (Function) Callback to be called with (err, assertion) where 'err'
* can be an Error or NULL, and 'assertion' can be NULL or a valid
* BrowserID assertion. If no callback is provided, an exception is
* thrown.
*
* @param win
* (Window) A contentWindow that has a valid document loaded. If this
* argument is provided the user will be asked to login in the context
* of the document currently loaded in this window.
*
* The audience of the assertion will be set to the domain of the
* loaded document, and the "audience" property in the "options"
* argument (if provided), will be ignored. The email to which this
* assertion issued will be selected by the user when they login (and
* "requiredEmail" or "sameEmailAs", if provided, will be ignored). If
* the user chooses to not login, this call will fail.
*
* Be aware! The provided contentWindow must also have loaded the
* BrowserID include.js shim for this to work! This behavior is
* temporary until we implement native support for navigator.id.
*
* @param options
* (Object) Currently an empty object. Present for future compatiblity
* when options for a login case may be added. Any properties, if
* present, are ignored.
*/
getAssertionWithLogin: function getAssertionWithLogin(cb, win, options) {
if (!cb) {
throw new Error("getAssertionWithLogin called without a callback");
}
if (!win) {
throw new Error("getAssertionWithLogin called without a window");
}
this._getAssertionWithLogin(cb, win);
},
/**
* Internal implementation methods begin here
*/
// Try to get the user's email(s). If user isn't logged in, this will be empty
_getEmails: function _getEmails(cb, options, sandbox) {
let self = this;
function callback(res) {
let emails = {};
try {
emails = JSON.parse(res);
} catch (e) {
self._log.error("Exception in JSON.parse for _getAssertion: " + e);
}
self._gotEmails(emails, sandbox, cb, options);
}
sandbox.box.importFunction(callback);
let scriptText =
"var list = window.BrowserID.User.getStoredEmailKeypairs();" +
"callback(JSON.stringify(list));";
Cu.evalInSandbox(scriptText, sandbox.box, "1.8", this.ID_URI, 1);
},
// Received a list of emails from BrowserID for current user
_gotEmails: function _gotEmails(emails, sandbox, cb, options) {
let keys = Object.keys(emails);
// If list is empty, user is not logged in, or doesn't have a default email.
if (!keys.length) {
sandbox.free();
let err = "User is not logged in, or no emails were found";
this._log.error(err);
try {
cb(new Error(err), null);
} catch(e) {
this._log.warn("Callback threw in _gotEmails " +
CommonUtils.exceptionStr(e));
}
return;
}
// User is logged in. For which email shall we get an assertion?
// Case 1: Explicitely provided
if (options.requiredEmail) {
this._getAssertionWithEmail(
sandbox, cb, options.requiredEmail, options.audience
);
return;
}
// Case 2: Derive from a given domain
if (options.sameEmailAs) {
this._getAssertionWithDomain(
sandbox, cb, options.sameEmailAs, options.audience
);
return;
}
// Case 3: Default email
this._getAssertionWithEmail(
sandbox, cb, keys[0], options.audience
);
return;
},
/**
* Open a login window and ask the user to login, returning the assertion
* generated as a result to the caller.
*/
_getAssertionWithLogin: function _getAssertionWithLogin(cb, win) {
// We're executing navigator.id.get as a content script in win.
// This results in a popup that we will temporarily unblock.
let pm = Services.perms;
let origin = Services.io.newURI(
win.wrappedJSObject.location.toString(), null, null
);
let oldPerm = pm.testExactPermission(origin, "popup");
try {
pm.add(origin, "popup", pm.ALLOW_ACTION);
} catch(e) {
this._log.warn("Setting popup blocking to false failed " + e);
}
// Open sandbox and execute script. This sandbox will be GC'ed.
let sandbox = new Cu.Sandbox(win, {
wantXrays: false,
sandboxPrototype: win
});
let self = this;
function callback(val) {
// Set popup blocker permission to original value.
try {
pm.add(origin, "popup", oldPerm);
} catch(e) {
this._log.warn("Setting popup blocking to original value failed " + e);
}
if (val) {
self._log.info("_getAssertionWithLogin succeeded");
try {
cb(null, val);
} catch(e) {
self._log.warn("Callback threw in _getAssertionWithLogin " +
CommonUtils.exceptionStr(e));
}
} else {
let msg = "Could not obtain assertion in _getAssertionWithLogin";
self._log.error(msg);
try {
cb(new Error(msg), null);
} catch(e) {
self._log.warn("Callback threw in _getAssertionWithLogin " +
CommonUtils.exceptionStr(e));
}
}
}
sandbox.importFunction(callback);
function doGetAssertion() {
self._log.info("_getAssertionWithLogin Started");
let scriptText = "window.navigator.id.get(" +
" callback, {allowPersistent: true}" +
");";
Cu.evalInSandbox(scriptText, sandbox, "1.8", self.ID_URI, 1);
}
// Sometimes the provided win hasn't fully loaded yet
if (!win.document || (win.document.readyState != "complete")) {
win.addEventListener("DOMContentLoaded", function _contentLoaded() {
win.removeEventListener("DOMContentLoaded", _contentLoaded, false);
doGetAssertion();
}, false);
} else {
doGetAssertion();
}
},
/**
* Gets an assertion for the specified 'email' and 'audience'
*/
_getAssertionWithEmail: function _getAssertionWithEmail(sandbox, cb, email,
audience) {
let self = this;
function onSuccess(res) {
// Cleanup first.
sandbox.free();
// The internal API sometimes calls onSuccess even though no assertion
// could be obtained! Double check:
if (!res) {
let msg = "BrowserID.User.getAssertion empty assertion for " + email;
self._log.error(msg);
try {
cb(new Error(msg), null);
} catch(e) {
self._log.warn("Callback threw in _getAssertionWithEmail " +
CommonUtils.exceptionStr(e));
}
return;
}
// Success
self._log.info("BrowserID.User.getAssertion succeeded");
try {
cb(null, res);
} catch(e) {
self._log.warn("Callback threw in _getAssertionWithEmail " +
CommonUtils.exceptionStr(e));
}
}
function onError(err) {
sandbox.free();
self._log.info("BrowserID.User.getAssertion failed");
try {
cb(err, null);
} catch(e) {
self._log.warn("Callback threw in _getAssertionWithEmail " +
CommonUtils.exceptionStr(e));
}
}
sandbox.box.importFunction(onSuccess);
sandbox.box.importFunction(onError);
self._log.info("_getAssertionWithEmail Started");
let scriptText =
"window.BrowserID.User.getAssertion(" +
"'" + email + "', " +
"'" + audience + "', " +
"onSuccess, " +
"onError" +
");";
Cu.evalInSandbox(scriptText, sandbox.box, "1.8", this.ID_URI, 1);
},
/**
* Gets the email which was used to login to 'domain'. If one was found,
* _getAssertionWithEmail is called to obtain the assertion.
*/
_getAssertionWithDomain: function _getAssertionWithDomain(sandbox, cb, domain,
audience) {
let self = this;
function onDomainSuccess(email) {
if (email) {
self._getAssertionWithEmail(sandbox, cb, email, audience);
} else {
sandbox.free();
try {
cb(new Error("No email found for _getAssertionWithDomain"), null);
} catch(e) {
self._log.warn("Callback threw in _getAssertionWithDomain " +
CommonUtils.exceptionStr(e));
}
}
}
sandbox.box.importFunction(onDomainSuccess);
// This wil tell us which email was used to login to "domain", if any.
self._log.info("_getAssertionWithDomain Started");
let scriptText =
"onDomainSuccess(window.BrowserID.Storage.site.get(" +
"'" + domain + "', " +
"'email'" +
"));";
Cu.evalInSandbox(scriptText, sandbox.box, "1.8", this.ID_URI, 1);
},
};
/**
* An object that represents a sandbox in an iframe loaded with uri. The
* callback provided to the constructor will be invoked when the sandbox is
* ready to be used. The callback will receive this object as its only argument
* and the prepared sandbox may be accessed via the "box" property.
*
* Please call free() when you are finished with the sandbox to explicitely free
* up all associated resources.
*
* @param cb
* (function) Callback to be invoked with a Sandbox, when ready.
* @param uri
* (String) URI to be loaded in the Sandbox.
*/
function Sandbox(cb, uri) {
this._uri = uri;
this._createFrame();
this._createSandbox(cb, uri);
}
Sandbox.prototype = {
/**
* Frees the sandbox and releases the iframe created to host it.
*/
free: function free() {
delete this.box;
this._container.removeChild(this._frame);
this._frame = null;
this._container = null;
},
/**
* Creates an empty, hidden iframe and sets it to the _iframe
* property of this object.
*
* @return frame
* (iframe) An empty, hidden iframe
*/
_createFrame: function _createFrame() {
let doc = Services.wm.getMostRecentWindow("navigator:browser").document;
// Insert iframe in to create docshell.
let frame = doc.createElement("iframe");
frame.setAttribute("type", "content");
frame.setAttribute("collapsed", "true");
doc.documentElement.appendChild(frame);
// Stop about:blank from being loaded.
let webNav = frame.docShell.QueryInterface(Ci.nsIWebNavigation);
webNav.stop(Ci.nsIWebNavigation.STOP_NETWORK);
// Set instance properties.
this._frame = frame;
this._container = doc.documentElement;
},
_createSandbox: function _createSandbox(cb, uri) {
let self = this;
this._frame.addEventListener(
"DOMContentLoaded",
function _makeSandboxContentLoaded(event) {
if (event.target.location.toString() != uri) {
return;
}
event.target.removeEventListener(
"DOMContentLoaded", _makeSandboxContentLoaded, false
);
let workerWindow = self._frame.contentWindow;
self.box = new Cu.Sandbox(workerWindow, {
wantXrays: false,
sandboxPrototype: workerWindow
});
cb(self);
},
true
);
// Load the iframe.
this._frame.docShell.loadURI(
uri,
this._frame.docShell.LOAD_FLAGS_NONE,
null, // referrer
null, // postData
null // headers
);
},
};
XPCOMUtils.defineLazyGetter(this, "BrowserID", function() {
return new BrowserIDService();
});

View File

@ -1,2 +1,9 @@
pref("services.aitc.browserid.url", "https://browserid.org/sign_in");
pref("services.aitc.browserid.log.level", "Debug");
pref("services.aitc.client.log.level", "Debug");
pref("services.aitc.storage.log.level", "Debug");pref("services.aitc.client.timeout", 120);
pref("services.aitc.storage.log.level", "Debug");
pref("services.aitc.client.timeout", 120);
pref("services.aitc.storage.log.level", "Debug");

View File

@ -14,3 +14,12 @@ MODULE = test_services_aitc
XPCSHELL_TESTS = unit
include $(topsrcdir)/config/rules.mk
_browser_files = \
mochitest/head.js \
mochitest/browser_id_simple.js \
mochitest/file_browser_id_mock.html \
$(NULL)
libs:: $(_browser_files)
$(INSTALL) $(foreach f,$^,"$f") $(DEPTH)/_tests/testing/mochitest/browser/$(relativesrcdir)

View File

@ -0,0 +1,44 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
const AUD = "http://foo.net";
function test() {
waitForExplicitFinish();
setEndpoint("browser_id_mock");
// Get an assertion for default email.
BrowserID.getAssertion(gotDefaultAssertion, {audience: AUD});
}
function gotDefaultAssertion(err, ast) {
is(err, null, "gotDefaultAssertion failed with " + err);
is(ast, "default@example.org_assertion_" + AUD,
"gotDefaultAssertion returned wrong assertion");
// Get an assertion for a specific email.
BrowserID.getAssertion(gotSpecificAssertion, {
requiredEmail: "specific@example.org",
audience: AUD
});
}
function gotSpecificAssertion(err, ast) {
is(err, null, "gotSpecificAssertion failed with " + err);
is(ast, "specific@example.org_assertion_" + AUD,
"gotSpecificAssertion returned wrong assertion");
// Get an assertion using sameEmailAs for another domain.
BrowserID.getAssertion(gotSameEmailAssertion, {
sameEmailAs: "http://zombo.com",
audience: AUD
});
}
function gotSameEmailAssertion(err, ast) {
is(err, null, "gotSameEmailAssertion failed with " + err);
is(ast, "assertion_for_sameEmailAs",
"gotSameEmailAssertion returned wrong assertion");
finish();
}

View File

@ -0,0 +1,52 @@
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
</head>
<body>
<p>Mock BrowserID endpoint for a logged-in user</p>
</body>
<script>
/**
* Object containing valid email/key paris for this user. An assertion is simply
* the string "_assertion_$audience" appended to the email. The exception is
* when the email address is "sameEmailAs@example.org" the assertion will
* be "assertion_for_sameEmailAs".
*/
var _emails = {
"default@example.org": "default@example.org_key",
"specific@example.org": "specific@example.org_key",
"sameEmailAs@example.org": "sameEmailAs@example.org_key"
};
var _sameEmailAs = "sameEmailAs@example.org";
// Mock internal API
window.BrowserID = {};
window.BrowserID.User = {
getStoredEmailKeypairs: function() {
return _emails;
},
getAssertion: function(email, audience, success, error) {
if (email == _sameEmailAs) {
success("assertion_for_sameEmailAs");
return;
}
if (email in _emails) {
success(email + "_assertion_" + audience);
return;
}
error("invalid email specified");
}
};
window.BrowserID.Storage = {
site: {
get: function(domain, key) {
if (key == "email") {
return _sameEmailAs;
}
return "";
}
}
};
</script>
</html>

View File

@ -0,0 +1,23 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
let tmp = {};
Components.utils.import("resource://gre/modules/Services.jsm");
Components.utils.import("resource://services-aitc/browserid.js", tmp);
const BrowserID = tmp.BrowserID;
const testPath = "http://mochi.test:8888/browser/services/aitc/tests/";
function loadURL(aURL, aCB) {
gBrowser.selectedBrowser.addEventListener("load", function () {
gBrowser.selectedBrowser.removeEventListener("load", arguments.callee, true);
is(gBrowser.currentURI.spec, aURL, "loaded expected URL");
aCB();
}, true);
gBrowser.loadURI(aURL);
}
function setEndpoint(name) {
let fullPath = testPath + "file_" + name + ".html";
Services.prefs.setCharPref("services.aitc.browserid.url", fullPath);
}

View File

@ -1,5 +1,6 @@
const modules = [
"client.js",
"browserid.js",
"storage.js"
];