667 lines
18 KiB
JavaScript
667 lines
18 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/. */
|
|
|
|
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;
|
|
},
|
|
};
|