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
const lazy = {};
ChromeUtils.defineLazyGetter(
lazy,
"JsonSchemaValidator",
() =>
ChromeUtils.importESModule(
"resource://gre/modules/components-utils/JsonSchemaValidator.sys.mjs"
).JsonSchemaValidator
);
ChromeUtils.defineLazyGetter(lazy, "logConsole", () =>
console.createInstance({
prefix: "GuardianTypes",
maxLogLevel: Services.prefs.getBoolPref("browser.ipProtection.log", false)
? "Debug"
: "Warn",
})
);
/**
* A ProxyPass contains a JWT token that can be used to authenticate the proxy service.
* It also contains the timestamp until which the token is valid.
* The Proxy will reject new connections if the token is not valid anymore.
*
* Immutable after creation.
*/
export class ProxyPass extends EventTarget {
#body = {
/** Not Before */
nbf: 0,
/** Expiration */
exp: 0,
};
/**
* @param {string} token - The JWT to use for authentication.
*/
constructor(token) {
super();
if (typeof token !== "string") {
throw new TypeError(
"Invalid arguments for ProxyPass constructor, token is not a string"
);
}
this.token = token;
// Contains [header.body.signature]
const parts = this.token.split(".");
if (parts.length !== 3) {
throw new TypeError("Invalid token format");
}
try {
const body = JSON.parse(atob(parts[1]));
if (
!lazy.JsonSchemaValidator.validate(body, ProxyPass.bodySchema).valid
) {
throw new TypeError("Token body does not match schema");
}
this.#body = body;
} catch (error) {
throw new TypeError("Invalid token format: " + error.message);
}
}
isValid(now = Temporal.Now.instant()) {
// If the remaining duration is zero or positive, the pass is still valid.
return (
Temporal.Instant.compare(now, this.from) >= 0 &&
Temporal.Instant.compare(now, this.until) < 0
);
}
shouldRotate(now = Temporal.Now.instant()) {
if (!this.isValid(now)) {
return true;
}
return Temporal.Instant.compare(now, this.rotationTimePoint) >= 0;
}
get from() {
// nbf is in seconds since epoch
return Temporal.Instant.fromEpochMilliseconds(this.#body.nbf * 1000);
}
get until() {
// exp is in seconds since epoch
return Temporal.Instant.fromEpochMilliseconds(this.#body.exp * 1000);
}
/**
* Parses a ProxyPass from a Response object.
*
* @param {Response} response
* @returns {Promise<ProxyPass|null>} A promise that resolves to a ProxyPass instance or null if the response is invalid.
*/
static async fromResponse(response) {
if (!response.ok) {
lazy.logConsole.error(
`Failed to fetch proxy pass: ${response.status} ${response.statusText}`
);
return null;
}
try {
const responseData = await response.json();
const token = responseData?.token;
if (!token || typeof token !== "string") {
lazy.logConsole.error("Missing or invalid token in response");
return null;
}
return new ProxyPass(token);
} catch (error) {
lazy.logConsole.error("Error parsing proxy pass response:", error);
return null;
}
}
/**
* @type {Temporal.Instant} - The Point in time when the token should be rotated.
*/
get rotationTimePoint() {
return this.until.subtract(ProxyPass.ROTATION_TIME);
}
asBearerToken() {
return `Bearer ${this.token}`;
}
// Rotate 2 Minutes from the End Time
static ROTATION_TIME = Temporal.Duration.from({ minutes: 2 });
static get bodySchema() {
return {
title: "JWT Claims",
type: "object",
properties: {
sub: {
type: "string",
description: "Subject identifier",
},
aud: {
type: "string",
format: "uri",
description: "Audience for which the token is intended",
},
iat: {
type: "integer",
description: "Issued-at time (seconds since Unix epoch)",
},
nbf: {
type: "integer",
description: "Not-before time (seconds since Unix epoch)",
},
exp: {
type: "integer",
description: "Expiration time (seconds since Unix epoch)",
},
iss: {
type: "string",
description: "Issuer identifier",
},
},
required: ["sub", "aud", "iat", "nbf", "exp", "iss"],
additionalProperties: true,
};
}
}
/**
* Represents a user's Entitlement for the Proxy Service of Guardian.
* If a user has an entitlement, they may access the proxy service.
*
* Immutable after creation.
*/
export class Entitlement {
/** True if the User has any valid subscription plan to the Mozilla VPN (not firefox VPN) */
subscribed = false;
/** The Guardian User ID */
uid = 0;
/** The maximum number of bytes allowed for the user */
maxBytes = BigInt(0);
constructor(
args = {
subscribed: false,
uid: 0,
maxBytes: "0",
}
) {
this.subscribed = args.subscribed;
this.uid = args.uid;
this.maxBytes = BigInt(args.maxBytes);
Object.freeze(this);
}
static fromResponse(response) {
if (!response.ok) {
return null;
}
return response.json().then(data => {
const result = lazy.JsonSchemaValidator.validate(
data,
Entitlement.schema
);
if (!result.valid) {
return null;
}
return new Entitlement(data);
});
}
static get schema() {
return {
title: "Entitlement",
type: "object",
properties: {
subscribed: {
type: "boolean",
},
uid: {
type: "integer",
},
maxBytes: {
type: "string",
description:
"A BigInt string representing the maximum number of bytes allowed for the user",
},
},
required: ["subscribed", "uid", "maxBytes"],
additionalProperties: true,
};
}
toString() {
return JSON.stringify({
...this,
maxBytes: this.maxBytes.toString(),
});
}
}
/**
* Represents usage tracking information for the Proxy Service.
* Contains data about quota limits, remaining quota, and reset time.
*
* Immutable after creation.
*/
export class ProxyUsage {
/** @type {bigint} - Maximum bytes allowed */
max = BigInt(0);
/** @type {bigint} - Remaining bytes available */
remaining = BigInt(0);
/** @type {Temporal.Instant} - When the usage quota resets */
reset = null;
/**
* @param {string} max - Maximum bytes allowed (as string for BigInt parsing)
* @param {string} remaining - Remaining bytes available (as string for BigInt parsing)
* @param {string} reset - ISO 8601 timestamp when quota resets
*/
constructor(max, remaining, reset) {
this.max = BigInt(max);
if (this.max < BigInt(0)) {
throw new TypeError("max must be non-negative");
}
this.remaining = BigInt(remaining);
if (this.remaining < BigInt(0)) {
throw new TypeError("remaining must be non-negative");
}
if (this.remaining > this.max) {
throw new TypeError("remaining cannot exceed max");
}
this.reset = Temporal.Instant.from(reset);
Object.freeze(this);
}
static fromResponse(response) {
const getOrThrow = headerName => {
const value = response.headers.get(headerName);
if (!value) {
throw new TypeError(`Missing required header: ${headerName}`);
}
return value;
};
const quotaLimit = getOrThrow("X-Quota-Limit");
const quotaRemaining = getOrThrow("X-Quota-Remaining");
const quotaReset = getOrThrow("X-Quota-Reset");
return new ProxyUsage(quotaLimit, quotaRemaining, quotaReset);
}
}