gecko/b2g/components/SignInToWebsite.jsm

465 lines
16 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/. */
/*
* SignInToWebsite.jsm - UX Controller and means for accessing identity
* cookies on behalf of relying parties.
*
* Currently, the b2g security architecture isolates web applications
* so that each window has access only to a local cookie jar:
*
* To prevent Web apps from interfering with one another, each one is
* hosted on a separate domain, and therefore may only access the
* resources associated with its domain. These resources include
* things such as IndexedDB databases, cookies, offline storage,
* and so forth.
*
* -- https://developer.mozilla.org/en-US/docs/Mozilla/Firefox_OS/Security/Security_model
*
* As a result, an authentication system like Persona cannot share its
* cookie jar with multiple relying parties, and so would require a
* fresh login request in every window. This would not be a good
* experience.
*
*
* In order for navigator.id.request() to maintain state in a single
* cookie jar, we cause all Persona interactions to take place in a
* content context that is launched by the system application, with the
* result that Persona has a single cookie jar that all Relying
* Parties can use. Since of course those Relying Parties cannot
* reach into the system cookie jar, the Controller in this module
* provides a way to get messages and data to and fro between the
* Relying Party in its window context, and the Persona internal api
* in its context.
*
* On the Relying Party's side, say a web page invokes
* navigator.id.watch(), to register callbacks, and then
* navigator.id.request() to request an assertion. The navigator.id
* calls are provided by nsDOMIdentity. nsDOMIdentity messages down
* to the privileged DOMIdentity code (using cpmm and ppmm message
* managers). DOMIdentity stores the state of Relying Party flows
* using an Identity service (MinimalIdentity.jsm), and emits messages
* requesting Persona functions (doWatch, doReady, doLogout).
*
* The Identity service sends these observer messages to the
* Controller in this module, which in turn triggers content to open a
* window to host the Persona js. If user interaction is required,
* content will open the trusty UI. If user interaction is not required,
* and we only need to get to Persona functions, content will open a
* hidden iframe. In either case, a window is opened into which the
* controller causes the script identity.js to be injected. This
* script provides the glue between the in-page javascript and the
* pipe back down to the Controller, translating navigator.internal
* function callbacks into messages sent back to the Controller.
*
* As a result, a navigator.internal function in the hosted popup or
* iframe can call back to the injected identity.js (doReady, doLogin,
* or doLogout). identity.js callbacks send messages back through the
* pipe to the Controller. The controller invokes the corresponding
* function on the Identity Service (doReady, doLogin, or doLogout).
* The IdentityService calls the corresponding callback for the
* correct Relying Party, which causes DOMIdentity to send a message
* up to the Relying Party through nsDOMIdentity
* (Identity:RP:Watch:OnLogin etc.), and finally, nsDOMIdentity
* receives these messages and calls the original callback that the
* Relying Party registered (navigator.id.watch(),
* navigator.id.request(), or navigator.id.logout()).
*/
"use strict";
this.EXPORTED_SYMBOLS = ["SignInToWebsiteController"];
const Ci = Components.interfaces;
const Cu = Components.utils;
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "getRandomId",
"resource://gre/modules/identity/IdentityUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "IdentityService",
"resource://gre/modules/identity/MinimalIdentity.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Logger",
"resource://gre/modules/identity/LogUtils.jsm");
// The default persona uri; can be overwritten with toolkit.identity.uri pref.
// Do this if you want to repoint to a different service for testing.
// There's no point in setting up an observer to monitor the pref, as b2g prefs
// can only be overwritten when the profie is recreated. So just get the value
// on start-up.
let kPersonaUri = "https://firefoxos.persona.org";
try {
kPersonaUri = Services.prefs.getCharPref("toolkit.identity.uri");
} catch(noSuchPref) {
// stick with the default value
}
// JS shim that contains the callback functions that
// live within the identity UI provisioning frame.
const kIdentityShimFile = "chrome://browser/content/identity.js";
// Type of MozChromeEvents to handle id dialogs.
const kOpenIdentityDialog = "id-dialog-open";
const kDoneIdentityDialog = "id-dialog-done";
const kCloseIdentityDialog = "id-dialog-close-iframe";
// Observer messages to communicate to shim
const kIdentityDelegateWatch = "identity-delegate-watch";
const kIdentityDelegateRequest = "identity-delegate-request";
const kIdentityDelegateLogout = "identity-delegate-logout";
const kIdentityDelegateFinished = "identity-delegate-finished";
const kIdentityDelegateReady = "identity-delegate-ready";
const kIdentityControllerDoMethod = "identity-controller-doMethod";
function log(...aMessageArgs) {
Logger.log.apply(Logger, ["SignInToWebsiteController"].concat(aMessageArgs));
}
log("persona uri =", kPersonaUri);
/*
* ContentInterface encapsulates the our content functions. There are only two:
*
* getContent - return the current content window
* sendChromeEvent - send a chromeEvent from the browser shell
*/
let ContentInterface = {
_getBrowser: function SignInToWebsiteController__getBrowser() {
return Services.wm.getMostRecentWindow("navigator:browser");
},
getContent: function SignInToWebsiteController_getContent() {
return this._getBrowser().getContentWindow();
},
sendChromeEvent: function SignInToWebsiteController_sendChromeEvent(detail) {
detail.uri = kPersonaUri;
this._getBrowser().shell.sendChromeEvent(detail);
}
};
function Pipe() {
this._watchers = [];
}
Pipe.prototype = {
init: function pipe_init() {
Services.obs.addObserver(this, "identity-child-process-shutdown", false);
Services.obs.addObserver(this, "identity-controller-unwatch", false);
},
uninit: function pipe_uninit() {
Services.obs.removeObserver(this, "identity-child-process-shutdown");
Services.obs.removeObserver(this, "identity-controller-unwatch");
},
observe: function Pipe_observe(aSubject, aTopic, aData) {
let options = {};
if (aSubject) {
options = aSubject.wrappedJSObject;
}
switch (aTopic) {
case "identity-child-process-shutdown":
log("pipe removing watchers by message manager");
this._removeWatchers(null, options.messageManager);
break;
case "identity-controller-unwatch":
log("unwatching", options.id);
this._removeWatchers(options.id, options.messageManager);
break;
}
},
_addWatcher: function Pipe__addWatcher(aId, aMm) {
log("Adding watcher with id", aId);
for (let i = 0; i < this._watchers.length; ++i) {
let watcher = this._watchers[i];
if (this._watcher.id === aId) {
watcher.count++;
return;
}
}
this._watchers.push({id: aId, count: 1, mm: aMm});
},
_removeWatchers: function Pipe__removeWatcher(aId, aMm) {
let checkId = aId !== null;
let index = -1;
for (let i = 0; i < this._watchers.length; ++i) {
let watcher = this._watchers[i];
if (watcher.mm === aMm &&
(!checkId || (checkId && watcher.id === aId))) {
index = i;
break;
}
}
if (index !== -1) {
if (checkId) {
if (--(this._watchers[index].count) === 0) {
this._watchers.splice(index, 1);
}
} else {
this._watchers.splice(index, 1);
}
}
if (this._watchers.length === 0) {
log("No more watchers; clean up persona host iframe");
let detail = {
type: kCloseIdentityDialog
};
log('telling content to close the dialog');
// tell content to close the dialog
ContentInterface.sendChromeEvent(detail);
}
},
communicate: function(aRpOptions, aContentOptions, aMessageCallback) {
let rpID = aRpOptions.id;
let rpMM = aRpOptions.mm;
if (rpMM) {
this._addWatcher(rpID, rpMM);
}
log("RP options:", aRpOptions, "\n content options:", aContentOptions);
// This content variable is injected into the scope of
// kIdentityShimFile, where it is used to access the BrowserID object
// and its internal API.
let content = ContentInterface.getContent();
let mm = null;
let uuid = getRandomId();
let self = this;
if (!content) {
log("ERROR: what the what? no content window?");
// aErrorCb.onresult("NO_CONTENT_WINDOW");
return;
}
function removeMessageListeners() {
if (mm) {
mm.removeMessageListener(kIdentityDelegateFinished, identityDelegateFinished);
mm.removeMessageListener(kIdentityControllerDoMethod, aMessageCallback);
}
}
function identityDelegateFinished() {
removeMessageListeners();
let detail = {
type: kDoneIdentityDialog,
showUI: aContentOptions.showUI || false,
id: kDoneIdentityDialog + "-" + uuid,
requestId: aRpOptions.id
};
log('received delegate finished; telling content to close the dialog');
ContentInterface.sendChromeEvent(detail);
self._removeWatchers(rpID, rpMM);
}
content.addEventListener("mozContentEvent", function getAssertion(evt) {
let msg = evt.detail;
if (!msg.id.match(uuid)) {
return;
}
switch (msg.id) {
case kOpenIdentityDialog + '-' + uuid:
if (msg.type === 'cancel') {
// The user closed the dialog. Clean up and call cancel.
content.removeEventListener("mozContentEvent", getAssertion);
removeMessageListeners();
aMessageCallback({json: {method: "cancel"}});
} else {
// The window has opened. Inject the identity shim file containing
// the callbacks in the content script. This could be either the
// visible popup that the user interacts with, or it could be an
// invisible frame.
let frame = evt.detail.frame;
let frameLoader = frame.QueryInterface(Ci.nsIFrameLoaderOwner).frameLoader;
mm = frameLoader.messageManager;
try {
mm.loadFrameScript(kIdentityShimFile, true, true);
log("Loaded shim", kIdentityShimFile);
} catch (e) {
log("Error loading", kIdentityShimFile, "as a frame script:", e);
}
// There are two messages that the delegate can send back: a "do
// method" event, and a "finished" event. We pass the do-method
// events straight to the caller for interpretation and handling.
// If we receive a "finished" event, then the delegate is done, so
// we shut down the pipe and clean up.
mm.addMessageListener(kIdentityControllerDoMethod, aMessageCallback);
mm.addMessageListener(kIdentityDelegateFinished, identityDelegateFinished);
mm.sendAsyncMessage(aContentOptions.message, aRpOptions);
}
break;
case kDoneIdentityDialog + '-' + uuid:
// Received our assertion. The message manager callbacks will handle
// communicating back to the IDService. All we have to do is remove
// this listener.
content.removeEventListener("mozContentEvent", getAssertion);
break;
default:
log("ERROR - Unexpected message: id=" + msg.id + ", type=" + msg.type + ", errorMsg=" + msg.errorMsg);
break;
}
});
// Tell content to open the identity iframe or trusty popup. The parameter
// showUI signals whether user interaction is needed. If it is, content will
// open a dialog; if not, a hidden iframe. In each case, BrowserID is
// available in the context.
let detail = {
type: kOpenIdentityDialog,
showUI: aContentOptions.showUI || false,
id: kOpenIdentityDialog + "-" + uuid,
requestId: aRpOptions.id
};
ContentInterface.sendChromeEvent(detail);
}
};
/*
* The controller sits between the IdentityService used by DOMIdentity
* and a content process launches an (invisible) iframe or (visible)
* trusty UI. Using an injected js script (identity.js), the
* controller enables the content window to access the persona identity
* storage in the system cookie jar and send events back via the
* controller into IdentityService and DOM, and ultimately up to the
* Relying Party, which is open in a different window context.
*/
this.SignInToWebsiteController = {
/*
* Initialize the controller. To use a different content communication pipe,
* such as when mocking it in tests, pass aOptions.pipe.
*/
init: function SignInToWebsiteController_init(aOptions) {
aOptions = aOptions || {};
this.pipe = aOptions.pipe || new Pipe();
Services.obs.addObserver(this, "identity-controller-watch", false);
Services.obs.addObserver(this, "identity-controller-request", false);
Services.obs.addObserver(this, "identity-controller-logout", false);
},
uninit: function SignInToWebsiteController_uninit() {
Services.obs.removeObserver(this, "identity-controller-watch");
Services.obs.removeObserver(this, "identity-controller-request");
Services.obs.removeObserver(this, "identity-controller-logout");
},
observe: function SignInToWebsiteController_observe(aSubject, aTopic, aData) {
log("observe: received", aTopic, "with", aData, "for", aSubject);
let options = null;
if (aSubject) {
options = aSubject.wrappedJSObject;
}
switch (aTopic) {
case "identity-controller-watch":
this.doWatch(options);
break;
case "identity-controller-request":
this.doRequest(options);
break;
case "identity-controller-logout":
this.doLogout(options);
break;
default:
Logger.reportError("SignInToWebsiteController", "Unknown observer notification:", aTopic);
break;
}
},
/*
* options: method required - name of method to invoke
* assertion optional
*/
_makeDoMethodCallback: function SignInToWebsiteController__makeDoMethodCallback(aRpId) {
return function SignInToWebsiteController_methodCallback(aOptions) {
let message = aOptions.json;
if (typeof message === 'string') {
message = JSON.parse(message);
}
switch (message.method) {
case "ready":
IdentityService.doReady(aRpId);
break;
case "login":
if (message._internalParams) {
IdentityService.doLogin(aRpId, message.assertion, message._internalParams);
} else {
IdentityService.doLogin(aRpId, message.assertion);
}
break;
case "logout":
IdentityService.doLogout(aRpId);
break;
case "cancel":
IdentityService.doCancel(aRpId);
break;
default:
log("WARNING: wonky method call:", message.method);
break;
}
};
},
doWatch: function SignInToWebsiteController_doWatch(aRpOptions) {
// dom prevents watch from being called twice
let contentOptions = {
message: kIdentityDelegateWatch,
showUI: false
};
this.pipe.communicate(aRpOptions, contentOptions,
this._makeDoMethodCallback(aRpOptions.id));
},
/**
* The website is requesting login so the user must choose an identity to use.
*/
doRequest: function SignInToWebsiteController_doRequest(aRpOptions) {
log("doRequest", aRpOptions);
let contentOptions = {
message: kIdentityDelegateRequest,
showUI: true
};
this.pipe.communicate(aRpOptions, contentOptions,
this._makeDoMethodCallback(aRpOptions.id));
},
/*
*
*/
doLogout: function SignInToWebsiteController_doLogout(aRpOptions) {
log("doLogout", aRpOptions);
let contentOptions = {
message: kIdentityDelegateLogout,
showUI: false
};
this.pipe.communicate(aRpOptions, contentOptions,
this._makeDoMethodCallback(aRpOptions.id));
}
};