commit 21adfe40d8ff9bdcf3abcbf545e0a6feec142e10
parent 5ad12cdc1dc72f8fc8fcc17cf206984b8ca85e29
Author: Sebastian Streich <sstreich@mozilla.com>
Date: Mon, 15 Dec 2025 13:48:38 +0000
Bug 1988748 - Have ProxyPass use token Time Data r=ip-protection-reviewers,baku
Differential Revision: https://phabricator.services.mozilla.com/D275604
Diffstat:
6 files changed, 145 insertions(+), 80 deletions(-)
diff --git a/browser/components/ipprotection/GuardianClient.sys.mjs b/browser/components/ipprotection/GuardianClient.sys.mjs
@@ -278,12 +278,16 @@ export class GuardianClient {
* 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.
- * @param {Temporal.Instant} until -The Point in time when the token becomes invalid.
- * @param {Temporal.Instant} from - The Point in time when the token becomes valid.
*/
- constructor(token, until, from = Temporal.Now.instant()) {
+ constructor(token) {
super();
if (typeof token !== "string") {
throw new TypeError(
@@ -291,16 +295,19 @@ export class ProxyPass extends EventTarget {
);
}
this.token = token;
- this.from = from;
- this.until = until;
- const [header, body] = this.token.split(".");
+ // Contains [header.body.signature]
+ const parts = this.token.split(".");
+ if (parts.length !== 3) {
+ throw new TypeError("Invalid token format");
+ }
try {
- const parses = [header, body].every(json =>
- JSON.parse(atob(json) != null)
- );
- if (!parses) {
- throw new TypeError("Invalid token format");
+ 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);
}
@@ -308,7 +315,10 @@ export class ProxyPass extends EventTarget {
isValid(now = Temporal.Now.instant()) {
// If the remaining duration is zero or positive, the pass is still valid.
- return Temporal.Instant.compare(now, this.until) < 0;
+ return (
+ Temporal.Instant.compare(now, this.from) >= 0 &&
+ Temporal.Instant.compare(now, this.until) < 0
+ );
}
shouldRotate(now = Temporal.Now.instant()) {
@@ -318,6 +328,16 @@ export class ProxyPass extends EventTarget {
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.
*
@@ -334,23 +354,6 @@ export class ProxyPass extends EventTarget {
}
try {
- // Get cache_control max-age value
- const cache_control = response.headers
- .get("cache-control")
- ?.match(/max-age=(\d+)/)?.[1];
-
- if (!cache_control) {
- console.error("Missing or invalid Cache-Control header");
- return null;
- }
-
- const max_age = parseInt(cache_control, 10);
- if (isNaN(max_age)) {
- console.error("Invalid max-age value in Cache-Control header");
- return null;
- }
- const from = Temporal.Now.instant();
- const until = from.add(Temporal.Duration.from({ seconds: max_age }));
// Parse JSON response
const responseData = await response.json();
const token = responseData?.token;
@@ -359,8 +362,7 @@ export class ProxyPass extends EventTarget {
console.error("Missing or invalid token in response");
return null;
}
-
- return new ProxyPass(token, until, from);
+ return new ProxyPass(token);
} catch (error) {
console.error("Error parsing proxy pass response:", error);
return null;
@@ -378,6 +380,43 @@ export class ProxyPass extends EventTarget {
}
// Rotate 10 Minutes from the End Time
static ROTATION_TIME = Temporal.Duration.from({ minutes: 10 });
+
+ static get bodySchema() {
+ return {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ 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,
+ };
+ }
}
/**
diff --git a/browser/components/ipprotection/tests/browser/head.js b/browser/components/ipprotection/tests/browser/head.js
@@ -387,10 +387,25 @@ async function cleanupExperiment() {
}
/* exported cleanupExperiment */
-function makePass() {
- const token =
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30";
- return new ProxyPass(token, Temporal.Now.instant().add({ hours: 24 }));
+function makePass(
+ from = Temporal.Now.instant(),
+ until = from.add({ hours: 24 })
+) {
+ const header = {
+ alg: "HS256",
+ typ: "JWT",
+ };
+ const body = {
+ iat: Math.floor(from.add({ seconds: 1 }).epochMilliseconds / 1000),
+ nbf: Math.floor(from.epochMilliseconds / 1000),
+ exp: Math.floor(until.epochMilliseconds / 1000),
+ sub: "proxy-pass-user-42",
+ aud: "guardian-proxy",
+ iss: "vpn.mozilla.org",
+ };
+ const encode = obj => btoa(JSON.stringify(obj));
+ const token = [encode(header), encode(body), "signature"].join(".");
+ return new ProxyPass(token);
}
/* exported makePass */
diff --git a/browser/components/ipprotection/tests/xpcshell/head.js b/browser/components/ipprotection/tests/xpcshell/head.js
@@ -90,11 +90,46 @@ function setupStubs(
sandbox.stub(IPProtectionService.guardian, "fetchProxyPass").resolves({
status: 200,
error: undefined,
- pass: {
- isValid: () => options.validProxyPass,
- shouldRotate: () => !options.validProxyPass,
- asBearerToken: () => "Bearer helloworld",
- rotationTimePoint: Temporal.Now.instant().add({ hours: 1 }),
- },
+ pass: new ProxyPass(
+ options.validProxyPass
+ ? createProxyPassToken()
+ : createExpiredProxyPassToken()
+ ),
});
}
+
+/**
+ * Creates a Token that can be fed as a Network Response from Guardian
+ * to simulate a Proxy Pass.
+ *
+ * @param {Temporal.Instant} from
+ * @param {Temporal.Instant} until
+ * @returns {string} JWT Token
+ */
+function createProxyPassToken(
+ from = Temporal.Now.instant(),
+ until = from.add({ hours: 24 })
+) {
+ const header = {
+ alg: "HS256",
+ typ: "JWT",
+ };
+ const body = {
+ iat: Math.floor(from.add({ seconds: 1 }).epochMilliseconds / 1000),
+ nbf: Math.floor(from.epochMilliseconds / 1000),
+ exp: Math.floor(until.epochMilliseconds / 1000),
+ sub: "proxy-pass-user-42",
+ aud: "guardian-proxy",
+ iss: "vpn.mozilla.org",
+ };
+ const encode = obj => btoa(JSON.stringify(obj));
+ return [encode(header), encode(body), "signature"].join(".");
+}
+/* exported createExpiredProxyPassToken */
+function createExpiredProxyPassToken() {
+ return createProxyPassToken(
+ Temporal.Now.instant().subtract({ hours: 2 }),
+ Temporal.Now.instant().subtract({ hours: 1 })
+ );
+}
+/* exported createExpiredProxyPassToken */
diff --git a/browser/components/ipprotection/tests/xpcshell/test_GuardianClient.js b/browser/components/ipprotection/tests/xpcshell/test_GuardianClient.js
@@ -310,7 +310,7 @@ add_task(async function test_fetchProxyPass() {
const testcases = [
{
name: "It should parse a valid response",
- sends: ok({ token: "header.payload.signature" }),
+ sends: ok({ token: createProxyPassToken() }),
expects: {
status: 200,
error: null,
@@ -336,17 +336,8 @@ add_task(async function test_fetchProxyPass() {
},
},
{
- name: "It should handle a missing Cache-Control header",
- sends: ok({ token: "header.payload.signature" }, { "Cache-Control": "" }),
- expects: {
- status: 200,
- error: "invalid_response",
- validPass: false,
- },
- },
- {
name: "It should handle an invalid token format",
- sends: ok({ token: "invalid-token-format" }),
+ sends: ok({ token: "header.body.signature" }),
expects: {
status: 200,
error: "invalid_response",
@@ -435,11 +426,6 @@ add_task(async function test_parseGuardianSuccessURL() {
});
add_task(async function test_proxyPassShouldRotate() {
- // Create a static synthetic JWT-like token for testing
- const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" }));
- const payload = btoa(JSON.stringify({ sub: "test", exp: 1702032000 }));
- const testToken = `${header}.${payload}.signature`;
-
const oneHour = Temporal.Duration.from({ hours: 1 });
const from = Temporal.Instant.from("2025-12-08T12:00:00Z"); // Static point in time
// The pass is valid for 1 hour from 'from'
@@ -471,7 +457,7 @@ add_task(async function test_proxyPassShouldRotate() {
testcases.forEach(({ name, currentTime, expects }) => {
info(`Running test case: ${name}`);
- const proxyPass = new ProxyPass(testToken, until, from);
+ const proxyPass = new ProxyPass(createProxyPassToken(from, until));
const result = proxyPass.shouldRotate(currentTime);
Assert.equal(
result,
diff --git a/browser/components/ipprotection/tests/xpcshell/test_IPProtectionPanel.js b/browser/components/ipprotection/tests/xpcshell/test_IPProtectionPanel.js
@@ -240,12 +240,7 @@ add_task(async function test_IPProtectionPanel_started_stopped() {
sandbox.stub(IPProtectionService.guardian, "fetchProxyPass").resolves({
status: 200,
error: undefined,
- pass: {
- isValid: () => true,
- shouldRotate: () => false,
- asBearerToken: () => "Bearer helloworld",
- rotationTimePoint: Temporal.Now.instant().add({ hours: 1 }),
- },
+ pass: new ProxyPass(createProxyPassToken()),
});
IPProtectionService.updateState();
diff --git a/browser/components/ipprotection/tests/xpcshell/test_IPProxyManager.js b/browser/components/ipprotection/tests/xpcshell/test_IPProxyManager.js
@@ -162,10 +162,7 @@ add_task(async function test_IPPProxyManager_reset() {
sandbox.stub(IPProtectionService.guardian, "fetchProxyPass").returns({
status: 200,
error: undefined,
- pass: {
- isValid: () => true,
- asBearerToken: () => "Bearer hello world",
- },
+ pass: new ProxyPass(createProxyPassToken()),
});
await IPPProxyManager.start();
@@ -254,12 +251,11 @@ add_task(async function test_IPPProxytates_active() {
sandbox.stub(IPProtectionService.guardian, "fetchProxyPass").resolves({
status: 200,
error: undefined,
- pass: {
- isValid: () => options.validProxyPass,
- shouldRotate: () => !options.validProxyPass,
- rotationTimePoint: Temporal.Now.instant().add({ hours: 1 }),
- asBearerToken: () => "Bearer helloworld",
- },
+ pass: new ProxyPass(
+ options.validProxyPass
+ ? createProxyPassToken()
+ : createExpiredProxyPassToken()
+ ),
});
const waitForReady = waitForEvent(
@@ -329,12 +325,11 @@ add_task(async function test_IPPProxytates_start_stop() {
sandbox.stub(IPProtectionService.guardian, "fetchProxyPass").resolves({
status: 200,
error: undefined,
- pass: {
- isValid: () => options.validProxyPass,
- shouldRotate: () => !options.validProxyPass,
- rotationTimePoint: Temporal.Now.instant().add({ hours: 1 }),
- asBearerToken: () => "Bearer helloworld",
- },
+ pass: new ProxyPass(
+ options.validProxyPass
+ ? createProxyPassToken()
+ : createExpiredProxyPassToken()
+ ),
});
const waitForReady = waitForEvent(