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
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",
);
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;
}