Differential Revision: https://phabricator.services.mozilla.com/D296163
421 lines
13 KiB
JavaScript
421 lines
13 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/. */
|
|
|
|
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
|
|
import {
|
|
Entitlement,
|
|
ProxyPass,
|
|
ProxyUsage,
|
|
} from "moz-src:///toolkit/components/ipprotection/GuardianTypes.sys.mjs";
|
|
|
|
const lazy = {};
|
|
|
|
ChromeUtils.defineLazyGetter(
|
|
lazy,
|
|
"hiddenBrowserManager",
|
|
() =>
|
|
ChromeUtils.importESModule("resource://gre/modules/HiddenFrame.sys.mjs")
|
|
.HiddenBrowserManager
|
|
);
|
|
ChromeUtils.defineLazyGetter(lazy, "logConsole", () =>
|
|
console.createInstance({
|
|
prefix: "GuardianClient",
|
|
maxLogLevel: Services.prefs.getBoolPref("browser.ipProtection.log", false)
|
|
? "Debug"
|
|
: "Warn",
|
|
})
|
|
);
|
|
|
|
if (Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT) {
|
|
throw new Error("Guardian.sys.mjs should only run in the parent process");
|
|
}
|
|
|
|
export const GUARDIAN_EXPERIMENT_TYPE = "alpha";
|
|
|
|
/**
|
|
* An HTTP Client to talk to the Guardian service.
|
|
* Allows to enroll users to the proxy service,
|
|
* fetch a proxy pass and check if the user is a proxy user.
|
|
*
|
|
*/
|
|
export class GuardianClient {
|
|
constructor() {
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
this,
|
|
"guardianEndpoint",
|
|
"browser.ipProtection.guardian.endpoint",
|
|
"https://vpn.mozilla.com"
|
|
);
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
this,
|
|
"fxaOrigin",
|
|
"identity.fxaccounts.remote.root"
|
|
);
|
|
}
|
|
/**
|
|
* Tries to enroll the user to the proxy service via a hidden browser sign-in flow.
|
|
* The FxA OAuth flow is completed silently using the existing FxA session cookies.
|
|
* If the user already has a proxy entitlement, the experiment type will update.
|
|
*
|
|
* @param { "alpha" | "beta" | "delta" | "gamma" } aExperimentType - The experiment type to enroll the user into.
|
|
* The experiment type controls which feature set the user will get in Firefox.
|
|
*
|
|
* @param { AbortSignal | null } aAbortSignal - An AbortSignal to cancel the operation.
|
|
* @returns {Promise<{error?: string, ok?: boolean}>}
|
|
*/
|
|
async enrollWithFxa(
|
|
aExperimentType = GUARDIAN_EXPERIMENT_TYPE,
|
|
aAbortSignal = null
|
|
) {
|
|
// We abort loading the page if the origin is not allowed.
|
|
const allowedOrigins = [
|
|
new URL(this.guardianEndpoint).origin,
|
|
new URL(this.fxaOrigin).origin,
|
|
];
|
|
const { loginURL, successURL, errorURL } =
|
|
this.enrollmentURLs(aExperimentType);
|
|
// If the browser is redirected to one of those urls
|
|
// we know we're done with the browser.
|
|
const finalizerURLs = [successURL, errorURL];
|
|
return await lazy.hiddenBrowserManager.withHiddenBrowser(async browser => {
|
|
const aborted = new Promise((_, reject) => {
|
|
aAbortSignal?.addEventListener("abort", () => {
|
|
browser.stop();
|
|
browser.remove();
|
|
reject(new Error("aborted"));
|
|
});
|
|
});
|
|
const finalEndpoint = waitUntilURL(browser, url => {
|
|
const urlObj = new URL(url);
|
|
if (url === "about:blank") {
|
|
return false;
|
|
}
|
|
if (!allowedOrigins.includes(urlObj.origin)) {
|
|
browser.stop();
|
|
browser.remove();
|
|
throw new Error(
|
|
`URL ${url} with origin ${urlObj.origin} is not allowed.`
|
|
);
|
|
}
|
|
if (
|
|
finalizerURLs.some(
|
|
finalizer =>
|
|
urlObj.pathname === finalizer.pathname &&
|
|
urlObj.origin === finalizer.origin
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
const loginURI = Services.io.newURI(loginURL.href);
|
|
if (!allowedOrigins.includes(loginURL.origin)) {
|
|
throw new Error(`Login URL origin ${loginURL.origin} is not allowed.`);
|
|
}
|
|
browser.loadURI(loginURI, {
|
|
triggeringPrincipal:
|
|
Services.scriptSecurityManager.createContentPrincipal(loginURI, {}),
|
|
});
|
|
|
|
const result = await Promise.race([finalEndpoint, aborted]);
|
|
return GuardianClient._parseGuardianSuccessURL(result);
|
|
});
|
|
}
|
|
|
|
static _parseGuardianSuccessURL(aUrl) {
|
|
if (!aUrl) {
|
|
return { error: "timeout", ok: false };
|
|
}
|
|
const url = new URL(aUrl);
|
|
const params = new URLSearchParams(url.search);
|
|
const error = params.get("error");
|
|
if (error) {
|
|
return { error, ok: false };
|
|
}
|
|
// Otherwise we should have:
|
|
// - a code in the URL query
|
|
if (!params.has("code")) {
|
|
return { error: "missing_code", ok: false };
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
/**
|
|
* Fetches a proxy pass from the Guardian service.
|
|
*
|
|
* @param {{token: string}} tokenHandle - short-lived OAuth token obtained from fxAccounts
|
|
* @param {AbortSignal} [abortSignal=null] - a signal to indicate the fetch should be aborted
|
|
* @returns {Promise<{error?: string, status?:number, pass?: ProxyPass, usage?: ProxyUsage|null, retryAfter?: string|null}>} Resolves with an object containing either an error string or the proxy pass data and a status code.
|
|
*
|
|
* Return values:
|
|
* - {pass, status, usage}: Success with proxy pass and optional usage info
|
|
* - {error: "login_needed", usage: null}: No auth token available
|
|
* - {status: 429, error: "quota_exceeded", usage, retryAfter}: Usage quota exceeded
|
|
* - {status, error: "invalid_response", usage}: Invalid response from server
|
|
* - {status, error: "parse_error", usage}: Failed to parse response
|
|
*
|
|
* Status codes to watch for:
|
|
* - 200: User is a proxy user and a new pass was fetched
|
|
* - 429: Usage quota exceeded
|
|
* - 403: The auth token was valid but the user is not a proxy user.
|
|
* - 401: The auth token was rejected.
|
|
* - 5xx: Internal guardian error.
|
|
*/
|
|
async fetchProxyPass(tokenHandle, abortSignal = null) {
|
|
const response = await fetch(this.#tokenURL, {
|
|
method: "GET",
|
|
cache: "no-cache",
|
|
headers: {
|
|
Authorization: `Bearer ${tokenHandle.token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
signal: abortSignal,
|
|
});
|
|
if (!response) {
|
|
return { error: "login_needed", usage: null };
|
|
}
|
|
const status = response.status;
|
|
|
|
let usage = null;
|
|
try {
|
|
usage = ProxyUsage.fromResponse(response);
|
|
} catch (error) {
|
|
lazy.logConsole.warn(
|
|
"Usage headers missing or invalid, continuing without usage:",
|
|
error
|
|
);
|
|
}
|
|
|
|
if (status === 429) {
|
|
const retryAfter = response.headers.get("Retry-After");
|
|
return {
|
|
status,
|
|
error: "quota_exceeded",
|
|
usage,
|
|
retryAfter,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const pass = await ProxyPass.fromResponse(response);
|
|
if (!pass) {
|
|
return { status, error: "invalid_response", usage };
|
|
}
|
|
return { pass, status, usage };
|
|
} catch (error) {
|
|
lazy.logConsole.error("Error parsing pass:", error);
|
|
return { status, error: "parse_error", usage };
|
|
}
|
|
}
|
|
/**
|
|
* Fetches the user's entitlement information.
|
|
*
|
|
* @param {{token: string}} tokenHandle - short-lived OAuth token obtained from fxAccounts
|
|
* @param {AbortSignal} [abortSignal=null] - a signal to indicate the fetch should be aborted
|
|
* @returns {Promise<{status?: number, entitlement?: Entitlement|null, error?:string}>} A promise that resolves to an object containing the HTTP status code and the user's entitlement information.
|
|
*
|
|
* Status codes to watch for:
|
|
* - 200: User is a proxy user and the entitlement information is available.
|
|
* - 404: User is not a proxy user, no entitlement information available.
|
|
* - 401: The auth token was rejected, probably a guardian/auth provider environment mismatch.
|
|
*/
|
|
async fetchUserInfo(tokenHandle, abortSignal = null) {
|
|
const response = await fetch(this.#statusURL, {
|
|
method: "GET",
|
|
headers: {
|
|
Authorization: `Bearer ${tokenHandle.token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
cache: "no-cache",
|
|
signal: abortSignal,
|
|
});
|
|
if (!response) {
|
|
return { error: "login_needed" };
|
|
}
|
|
const status = response.status;
|
|
try {
|
|
const entitlement = await Entitlement.fromResponse(response);
|
|
if (!entitlement) {
|
|
return { status, error: "parse_error" };
|
|
}
|
|
return {
|
|
status,
|
|
entitlement,
|
|
};
|
|
} catch (error) {
|
|
return { status, error: "parse_error" };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the user's proxy usage information, without fetching a new proxy pass.
|
|
*
|
|
* @param {{token: string}} tokenHandle - short-lived OAuth token obtained from fxAccounts
|
|
* @param {AbortSignal} abortSignal - Signal for when this function should be aborted
|
|
* @returns {ProxyUsage | null}
|
|
*/
|
|
async fetchProxyUsage(tokenHandle, abortSignal) {
|
|
const response = await fetch(this.#tokenURL, {
|
|
method: "HEAD",
|
|
cache: "no-cache",
|
|
signal: abortSignal,
|
|
headers: {
|
|
Authorization: `Bearer ${tokenHandle.token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
if (!response) {
|
|
return null;
|
|
}
|
|
try {
|
|
return ProxyUsage.fromResponse(response);
|
|
} catch (error) {
|
|
lazy.logConsole.warn(
|
|
"Usage headers missing or invalid, continuing without usage:",
|
|
error
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Activates the current FxA account with Guardian by presenting the FxA
|
|
* Bearer token directly.
|
|
*
|
|
* @param {{token: string}} tokenHandle - short-lived OAuth token obtained from fxAccounts
|
|
* @param {AbortSignal} [abortSignal=null]
|
|
* @returns {Promise<{ok: boolean, entitlement?: object, error?: string}>}
|
|
*/
|
|
async activate(tokenHandle, abortSignal = null) {
|
|
if (!tokenHandle) {
|
|
return { ok: false, error: "login_needed" };
|
|
}
|
|
const response = await fetch(this.#activateURL, {
|
|
method: "POST",
|
|
cache: "no-cache",
|
|
headers: {
|
|
Authorization: `Bearer ${tokenHandle.token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
signal: abortSignal,
|
|
});
|
|
if (!response.ok) {
|
|
return { ok: false, error: `status_${response.status}` };
|
|
}
|
|
try {
|
|
const entitlement = await Entitlement.fromResponse(response);
|
|
return { ok: true, entitlement };
|
|
} catch (error) {
|
|
return { ok: false, error: "parse_error" };
|
|
}
|
|
}
|
|
|
|
get #activateURL() {
|
|
const url = new URL(this.guardianEndpoint);
|
|
url.pathname = "/api/v1/fpn/activate";
|
|
return url;
|
|
}
|
|
|
|
/** This is the URL that will be used to fetch the proxy pass. */
|
|
get #tokenURL() {
|
|
const url = new URL(this.guardianEndpoint);
|
|
url.pathname = "/api/v1/fpn/token";
|
|
return url;
|
|
}
|
|
/**
|
|
* Returns the URLs needed to perform FxA enrollment with Guardian.
|
|
*
|
|
* @param {"alpha"|"beta"|"delta"|"gamma"} experimentType
|
|
* @returns {{ loginURL: URL, successURL: URL, errorURL: URL }}
|
|
*/
|
|
enrollmentURLs(experimentType = GUARDIAN_EXPERIMENT_TYPE) {
|
|
const loginURL = new URL(this.guardianEndpoint);
|
|
loginURL.pathname = "/api/v1/fpn/auth";
|
|
loginURL.searchParams.set("experiment", experimentType);
|
|
|
|
const successURL = new URL(this.guardianEndpoint);
|
|
successURL.pathname = "/oauth/success";
|
|
|
|
const errorURL = new URL(this.guardianEndpoint);
|
|
errorURL.pathname = "/api/v1/fpn/error";
|
|
|
|
return { loginURL, successURL, errorURL };
|
|
}
|
|
/** This is the URL that will be used to check the user's proxy status. */
|
|
get #statusURL() {
|
|
const url = new URL(this.guardianEndpoint);
|
|
url.pathname = "/api/v1/fpn/status";
|
|
return url;
|
|
}
|
|
guardianEndpoint = "";
|
|
}
|
|
|
|
/**
|
|
* Adds a strong reference to keep listeners alive until
|
|
* we're done with it.
|
|
* (From kungFuDeathGrip in XPCShellContentUtils.sys.mjs)
|
|
*/
|
|
const listeners = new Set();
|
|
|
|
/**
|
|
* Waits for a specific URL to be loaded in the browser.
|
|
*
|
|
* @param {*} browser - The browser instance to listen for URL changes.
|
|
* @param {(location: string) => boolean} predicate - A function that returns true if the location matches the desired URL.
|
|
* @returns {Promise<string>} A promise that resolves to the matching URL.
|
|
*/
|
|
async function waitUntilURL(browser, predicate) {
|
|
const prom = Promise.withResolvers();
|
|
let done = false;
|
|
const check = arg => {
|
|
if (done) {
|
|
return;
|
|
}
|
|
if (predicate(arg)) {
|
|
done = true;
|
|
listeners.delete(listener);
|
|
browser.removeProgressListener(listener);
|
|
prom.resolve(arg);
|
|
}
|
|
};
|
|
const listener = {
|
|
QueryInterface: ChromeUtils.generateQI([
|
|
"nsIWebProgressListener",
|
|
"nsISupportsWeakReference",
|
|
]),
|
|
|
|
// Runs the check after the document has stopped loading.
|
|
onStateChange(webProgress, request, stateFlags, status) {
|
|
request.QueryInterface(Ci.nsIChannel);
|
|
|
|
if (
|
|
webProgress.isTopLevel &&
|
|
stateFlags & Ci.nsIWebProgressListener.STATE_STOP &&
|
|
status !== Cr.NS_BINDING_ABORTED
|
|
) {
|
|
check(request.URI?.spec);
|
|
}
|
|
},
|
|
|
|
// Unused callbacks we still need to implement:
|
|
onLocationChange() {},
|
|
onProgressChange() {},
|
|
onStatusChange(_, request, status) {
|
|
if (Components.isSuccessCode(status)) {
|
|
return;
|
|
}
|
|
try {
|
|
const url = request.QueryInterface(Ci.nsIChannel).URI.spec;
|
|
check(url);
|
|
} catch (ex) {}
|
|
},
|
|
onSecurityChange() {},
|
|
onContentBlockingEvent() {},
|
|
};
|
|
listeners.add(listener);
|
|
browser.addProgressListener(listener, Ci.nsIWebProgress.NOTIFY_STATE_WINDOW);
|
|
const url = await prom.promise;
|
|
return url;
|
|
}
|