Source code

Revision control

Copy as Markdown

Other Tools

/* 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/. */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
ContextualIdentityService:
"moz-src:///toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs",
EveryWindow: "resource:///modules/EveryWindow.sys.mjs",
URILoadingHelper: "resource:///modules/URILoadingHelper.sys.mjs",
});
const EPHEMERAL_CONTAINER_PREFIX = "ephemeral-aswebauth-";
class ASWebAuthSession {
constructor(
service,
{
request,
uuid,
win,
browser,
callbackScheme,
userContextId,
hasCallback,
headers,
}
) {
this.service = service;
this.request = request;
this.uuid = uuid;
this.window = win;
this.browser = browser;
this.callbackScheme = callbackScheme;
this.userContextId = userContextId;
this.hasCallback = hasCallback;
this.headers = headers;
this.completed = false;
this.trackedBrowsers = new Set([browser]);
this.observingHeaders = false;
}
start() {
this.service.activeSessions.set(this.uuid, this);
this.window.gBrowser.tabContainer.addEventListener("TabClose", this);
this.window.addEventListener("unload", this);
this.addHeaderObserver();
}
cleanup() {
this.removeHeaderObserver();
if (this.window?.gBrowser) {
this.window.gBrowser.tabContainer.removeEventListener("TabClose", this);
}
if (this.window && !this.window.closed) {
this.window.removeEventListener("unload", this);
}
if (this.userContextId) {
lazy.ContextualIdentityService.remove(this.userContextId);
}
}
// Shared teardown for both complete() and cancel().
finish({ closeWindow = true } = {}) {
this.service.activeSessions.delete(this.uuid);
this.cleanup();
if (closeWindow && this.window && !this.window.closed) {
// The auth window is chromeless and dedicated to this request, so closing
// the whole window loses no other tabs.
this.window.close();
}
}
// Success path: complete the native request, then tear down.
complete(callbackURL) {
if (this.completed) {
return;
}
this.completed = true;
this.request.complete(callbackURL);
this.finish();
}
// Cancellation path: optionally notify the requesting app, then tear down.
cancel(notifyNative, options) {
if (this.completed) {
return;
}
this.completed = true;
if (notifyNative) {
this.request.cancel();
}
this.finish(options);
}
handleEvent(event) {
switch (event.type) {
case "TabClose":
this.onTabClose(event);
break;
case "unload":
this.onWindowUnload();
break;
}
}
observe(subject) {
let channel = subject.QueryInterface(Ci.nsIHttpChannel);
let bc = channel.loadInfo?.browsingContext;
let browser = bc?.top?.embedderElement;
// Only the top-level page load in this session's own auth tab.
if (
!channel.isDocument ||
!bc ||
bc !== bc.top ||
!this.hasBrowser(browser)
) {
return;
}
this.removeHeaderObserver();
for (let [name, value] of Object.entries(this.headers)) {
// Don't overwrite headers the browser already sends.
try {
channel.getRequestHeader(name);
continue;
} catch (e) {
// getRequestHeader throws NS_ERROR_NOT_AVAILABLE when the header isn't set.
}
try {
channel.setRequestHeader(name, value, false);
} catch (e) {
// The app supplied a name or value the channel rejects, skip it.
}
}
}
onTabClose(event) {
let browser = event.target.linkedBrowser;
if (!this.trackedBrowsers.delete(browser)) {
return;
}
if (
!this.completed &&
(browser === this.browser || !this.hasOpenTrackedBrowser())
) {
this.cancel(true);
}
}
onWindowUnload() {
if (this.service.activeSessions.get(this.uuid) === this) {
this.cancel(true, { closeWindow: false });
}
}
addHeaderObserver() {
if (!this.headers || !Object.keys(this.headers).length) {
return;
}
this.observingHeaders = true;
Services.obs.addObserver(this, "http-on-modify-request");
}
removeHeaderObserver() {
if (!this.observingHeaders) {
return;
}
Services.obs.removeObserver(this, "http-on-modify-request");
this.observingHeaders = false;
}
trackBrowserIfInSessionWindow(browser) {
if (this.hasBrowser(browser)) {
this.trackedBrowsers.add(browser);
}
}
hasOpenTrackedBrowser() {
for (let browser of this.trackedBrowsers) {
let tab = this.window?.gBrowser?.getTabForBrowser(browser);
if (tab && !tab.closing) {
return true;
}
}
return false;
}
hasBrowser(browser) {
return (
browser?.documentGlobal === this.window &&
!!this.window?.gBrowser?.getTabForBrowser(browser)
);
}
ownsBrowsingContext(bc) {
let visited = new Set();
for (let top = bc?.top; top && !visited.has(top); ) {
visited.add(top);
if (this.hasBrowser(top.embedderElement)) {
return true;
}
let opener = top.crossGroupOpener ?? top.opener;
top = opener?.top;
}
return false;
}
}
export const ASWebAuthSessionService = new (class ASWebAuthSessionService {
constructor() {
this.activeSessions = new Map();
this.pendingSetups = new Map();
this.initialized = false;
this.progressListener = {
onLocationChange: (browser, webProgress, _webRequest, location) =>
this.onLocationChange(browser, webProgress, location),
};
}
init() {
if (this.initialized) {
return;
}
this.initialized = true;
Services.obs.addObserver(this, "http-on-examine-response");
Services.obs.addObserver(this, "aswebauthsession-request-begin");
Services.obs.addObserver(this, "aswebauthsession-request-cancel");
Services.obs.addObserver(this, "aswebauthsession-native-ready");
lazy.EveryWindow.registerCallback(
"ASWebAuthSessionService",
win => win?.gBrowser?.addTabsProgressListener(this.progressListener),
win => win?.gBrowser?.removeTabsProgressListener(this.progressListener)
);
// Let the native handler replay requests that arrived before browser UI
// startup installed this observer.
Services.obs.notifyObservers(null, "aswebauthsession-service-ready");
}
uninit() {
if (!this.initialized) {
return;
}
this.initialized = false;
Services.obs.removeObserver(this, "http-on-examine-response");
Services.obs.removeObserver(this, "aswebauthsession-request-begin");
Services.obs.removeObserver(this, "aswebauthsession-request-cancel");
Services.obs.removeObserver(this, "aswebauthsession-native-ready");
lazy.EveryWindow.unregisterCallback("ASWebAuthSessionService");
for (let session of Array.from(this.activeSessions.values())) {
session.cancel(true);
}
// Cancel setups that are still opening their window so their native
// requests don't outlive shutdown.
for (let pending of this.pendingSetups.values()) {
if (!pending.cancelled) {
pending.cancelled = true;
pending.request.cancel();
}
}
this.pendingSetups.clear();
Services.obs.notifyObservers(null, "aswebauthsession-service-shutdown");
}
// Ephemeral containers only outlive their session when Firefox went away
// without running cleanup. Not called from init(): getPublicIdentities()
// would force the synchronous containers.json read onto the startup path,
// while load() reads it off the main thread.
async removeLeftoverEphemeralContainers() {
await lazy.ContextualIdentityService.load();
for (let identity of lazy.ContextualIdentityService.getPublicIdentities()) {
if (!identity.name?.startsWith(EPHEMERAL_CONTAINER_PREFIX)) {
continue;
}
// A request that arrived while this was waiting owns its container.
let uuid = identity.name.slice(EPHEMERAL_CONTAINER_PREFIX.length);
if (this.activeSessions.has(uuid) || this.pendingSetups.has(uuid)) {
continue;
}
lazy.ContextualIdentityService.remove(identity.userContextId);
}
}
// Handle request notifications from the native handler and response
// notifications used to catch redirect callbacks.
observe(subject, topic, data) {
switch (topic) {
case "http-on-examine-response":
this.onExamineResponse(subject);
break;
case "aswebauthsession-request-begin":
this.onBegin(subject);
break;
case "aswebauthsession-request-cancel":
this.onCancel(data);
break;
case "aswebauthsession-native-ready":
Services.obs.notifyObservers(null, "aswebauthsession-service-ready");
break;
}
}
shouldLoadCallback(contentLocation, loadInfo) {
if (!this.activeSessions.size) {
return Ci.nsIContentPolicy.ACCEPT;
}
let contentPolicyType = loadInfo.externalContentPolicyType;
if (
contentPolicyType !== Ci.nsIContentPolicy.TYPE_DOCUMENT &&
contentPolicyType !== Ci.nsIContentPolicy.TYPE_SUBDOCUMENT
) {
return Ci.nsIContentPolicy.ACCEPT;
}
let scheme = contentLocation.scheme?.toLowerCase();
if (!scheme || scheme === "http" || scheme === "https") {
return Ci.nsIContentPolicy.ACCEPT;
}
let session = this.findSessionForCallbackScheme(
loadInfo.browsingContext,
scheme
);
if (!session) {
return Ci.nsIContentPolicy.ACCEPT;
}
if (contentPolicyType !== Ci.nsIContentPolicy.TYPE_DOCUMENT) {
return Ci.nsIContentPolicy.REJECT_POLICY;
}
let callbackURL = contentLocation.spec;
// Avoid closing the window from inside the content policy check.
Services.tm.dispatchToMainThread(() => {
if (this.activeSessions.get(session.uuid) === session) {
session.complete(callbackURL);
}
});
return Ci.nsIContentPolicy.REJECT_POLICY;
}
onExamineResponse(subject) {
if (!this.activeSessions.size) {
return;
}
try {
let channel = subject.QueryInterface(Ci.nsIHttpChannel);
let bc = channel.loadInfo?.browsingContext;
// Auth callbacks are matched against main-frame navigations, so ignore
// subframe loads.
if (!channel.isDocument || !bc || bc !== bc.top) {
return;
}
let status = channel.responseStatus;
if (status < 300 || status >= 400) {
return;
}
let location = channel.getResponseHeader("Location");
if (!location) {
return;
}
let locationUri;
try {
locationUri = Services.io.newURI(location);
} catch (e) {
return;
}
let session = this.findSessionForCallbackScheme(bc, locationUri.scheme);
if (!session) {
return;
}
channel.cancel(Cr.NS_BINDING_ABORTED);
let callbackURL = location;
// Defer so we don't tear the window down from inside the
// http-on-examine-response notification.
Services.tm.dispatchToMainThread(() => {
if (this.activeSessions.get(session.uuid) === session) {
session.complete(callbackURL);
}
});
} catch (e) {
// Not an HTTP channel or missing Location header, ignore.
}
}
onLocationChange(browser, webProgress, location) {
if (!this.activeSessions.size || !location || !webProgress?.isTopLevel) {
return;
}
let scheme = location.scheme?.toLowerCase();
if (!scheme) {
return;
}
let bc = webProgress.browsingContext;
if (scheme === "https" || scheme === "http") {
for (let session of this.activeSessions.values()) {
if (session.hasCallback && session.ownsBrowsingContext(bc)) {
session.trackBrowserIfInSessionWindow(browser);
if (session.request.matchesCallbackURL(location.spec)) {
let callbackURL = location.spec;
Services.tm.dispatchToMainThread(() => {
if (this.activeSessions.get(session.uuid) === session) {
session.complete(callbackURL);
}
});
}
break;
}
}
return;
}
let session = this.findSessionForCallbackScheme(bc, scheme);
if (!session) {
return;
}
session.trackBrowserIfInSessionWindow(browser);
let callbackURL = location.spec;
// Avoid closing the window from inside the onLocationChange callback.
Services.tm.dispatchToMainThread(() => {
if (this.activeSessions.get(session.uuid) === session) {
session.complete(callbackURL);
}
});
}
findSessionForCallbackScheme(bc, callbackScheme) {
for (let session of this.activeSessions.values()) {
if (
session.callbackScheme === callbackScheme &&
session.ownsBrowsingContext(bc)
) {
return session;
}
}
return null;
}
waitForWindowClosed(win) {
if (!win || win.closed) {
return Promise.resolve();
}
return new Promise(resolve => {
function windowCloseObserver(subject, topic) {
if (topic === "domwindowclosed" && subject === win) {
Services.ww.unregisterNotification(windowCloseObserver);
resolve();
}
}
Services.ww.registerNotification(windowCloseObserver);
if (win.closed) {
Services.ww.unregisterNotification(windowCloseObserver);
resolve();
}
});
}
observeNextWindowOpen() {
let { promise, resolve } = Promise.withResolvers();
let windowOpenObserver = (subject, topic) => {
if (topic === "domwindowopened") {
Services.ww.unregisterNotification(windowOpenObserver);
resolve(subject);
}
};
Services.ww.registerNotification(windowOpenObserver);
return {
promise,
unregister() {
Services.ww.unregisterNotification(windowOpenObserver);
},
};
}
async openAuthWindow(url, { ephemeral, userContextId }) {
let { promise: browserPromise, resolve: resolveBrowser } =
Promise.withResolvers();
let openedWindow = this.observeNextWindowOpen();
try {
lazy.URILoadingHelper.openWebLinkIn(
lazy.BrowserWindowTracker.getTopWindow({ private: !!ephemeral }) ||
Services.appShell.hiddenDOMWindow,
url,
"chromeless",
{
private: !!ephemeral,
forceNonPrivate: !ephemeral,
fromExternal: true,
userContextId,
resolveOnContentBrowserCreated: resolveBrowser,
aswebauth: true,
}
);
} catch (e) {
openedWindow.unregister();
throw e;
}
let win = await openedWindow.promise;
// Wait for the browser to be created, but cancel setup if the window is closed first.
let browser = await Promise.race([
browserPromise,
this.waitForWindowClosed(win).then(() => null),
]);
if (browser?.documentGlobal !== win) {
return { win, browser: null };
}
return { win, browser };
}
cleanupFailedSetup(pending, win, userContextId) {
if (userContextId) {
lazy.ContextualIdentityService.remove(userContextId);
}
if (win && !win.closed) {
win.close();
}
if (!pending.cancelled) {
pending.request.cancel();
}
}
async onBegin(request) {
request = request.QueryInterface(Ci.nsIASWebAuthSessionRequest);
let uuid = request.uuid;
let callbackScheme = request.callbackScheme.toLowerCase();
let hasCallback = request.hasCallback;
let ephemeral = request.useEphemeralSession;
let headers = {};
for (let name of request.additionalHeaderNames) {
headers[name] = request.getAdditionalHeader(name);
}
let parsedURL = URL.parse(request.url);
if (!uuid || parsedURL?.protocol !== "https:") {
request.cancel();
return;
}
let pending = { cancelled: false, request };
this.pendingSetups.set(uuid, pending);
let userContextId = 0;
let win = null;
try {
if (ephemeral) {
let container = await lazy.ContextualIdentityService.create(
EPHEMERAL_CONTAINER_PREFIX + uuid,
"fingerprint",
"blue"
);
userContextId = container.userContextId;
}
let result = await this.openAuthWindow(parsedURL.href, {
ephemeral,
userContextId,
});
win = result.win;
if (!result.browser || win.closed || pending.cancelled) {
this.cleanupFailedSetup(pending, win, userContextId);
return;
}
win.focus();
let session = new ASWebAuthSession(this, {
request,
uuid,
win,
browser: result.browser,
callbackScheme,
userContextId,
hasCallback,
headers,
});
session.start();
userContextId = 0;
} catch (e) {
console.error("Failed to begin ASWebAuth request", e);
this.cleanupFailedSetup(pending, win, userContextId);
} finally {
this.pendingSetups.delete(uuid);
}
}
onCancel(uuid) {
let session = this.activeSessions.get(uuid);
if (session) {
session.cancel(false);
return;
}
let pending = this.pendingSetups.get(uuid);
if (pending) {
pending.cancelled = true;
}
}
})();
// Registered via components.conf. Catches custom-scheme callbacks
// (myapp://...) that don't hit the network, so the observers can't see them.
export function ASWebAuthSessionCallbackContentPolicy() {}
ASWebAuthSessionCallbackContentPolicy.prototype = {
classDescription: "ASWebAuthSession callback scheme content policy",
contractID: "@mozilla.org/aswebauthsession-content-policy;1",
classID: Components.ID("{59826a4f-abcf-4d1f-b049-c236572bc823}"),
QueryInterface: ChromeUtils.generateQI(["nsIContentPolicy"]),
shouldLoad(contentLocation, loadInfo) {
return ASWebAuthSessionService.shouldLoadCallback(
contentLocation,
loadInfo
);
},
shouldProcess() {
return Ci.nsIContentPolicy.ACCEPT;
},
};