GuardianClient.sys.mjs (19686B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 6 7 const lazy = {}; 8 9 ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => 10 ChromeUtils.importESModule( 11 "resource://gre/modules/FxAccounts.sys.mjs" 12 ).getFxAccountsSingleton() 13 ); 14 ChromeUtils.defineLazyGetter( 15 lazy, 16 "hiddenBrowserManager", 17 () => 18 ChromeUtils.importESModule("resource://gre/modules/HiddenFrame.sys.mjs") 19 .HiddenBrowserManager 20 ); 21 ChromeUtils.defineLazyGetter( 22 lazy, 23 "JsonSchemaValidator", 24 () => 25 ChromeUtils.importESModule( 26 "resource://gre/modules/components-utils/JsonSchemaValidator.sys.mjs" 27 ).JsonSchemaValidator 28 ); 29 30 if (Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT) { 31 throw new Error("Guardian.sys.mjs should only run in the parent process"); 32 } 33 34 /** 35 * An HTTP Client to talk to the Guardian service. 36 * Allows to enroll FxA users to the proxy service, 37 * fetch a proxy pass and check if the user is a proxy user. 38 * 39 */ 40 export class GuardianClient { 41 /** 42 * @param {typeof gConfig} [config] 43 */ 44 constructor(config = gConfig) { 45 this.guardianEndpoint = config.guardianEndpoint; 46 this.fxaOrigin = config.fxaOrigin; 47 this.withToken = config.withToken; 48 } 49 /** 50 * Checks the current user's FxA account to see if it is linked to the Guardian service. 51 * This should be used before attempting to check Entitlement info. 52 * 53 * @param { boolean } onlyCached - if true only the cached clients will be checked. 54 * @returns {Promise<boolean>} 55 * - True: The user is linked to the Guardian service, they might be a proxy user or have/had a VPN-Subscription. 56 * This needs to be followed up with a call to `fetchUserInfo()` to check if they are a proxy user. 57 * - False: The user is not linked to the Guardian service, they cannot be a proxy user. 58 */ 59 async isLinkedToGuardian(onlyCached = false) { 60 const guardian_clientId = CLIENT_ID_MAP[this.#successURL.origin]; 61 if (!guardian_clientId) { 62 // If we end up using an unknown successURL, we are definitely not linked to Guardian. 63 return false; 64 } 65 66 const cached_clients = await lazy.fxAccounts.listAttachedOAuthClients(); 67 if (cached_clients.some(client => client.id === guardian_clientId)) { 68 return true; 69 } 70 if (onlyCached) { 71 return false; 72 } 73 // If we don't have the client in the cache, we refresh it, just to be sure. 74 const refreshed_clients = 75 await lazy.fxAccounts.listAttachedOAuthClients(true); 76 if (refreshed_clients.some(client => client.id === guardian_clientId)) { 77 return true; 78 } 79 return false; 80 } 81 82 /** 83 * Tries to enroll the user to the proxy service. 84 * It will silently try to sign in the user into guardian using their FxA account. 85 * If the user already has a proxy entitlement, the experiment type will update. 86 * 87 * @param { "alpha" | "beta" | "delta" | "gamma" } aExperimentType - The experiment type to enroll the user into. 88 * The experiment type controls which feature set the user will get in Firefox. 89 * 90 * @param { AbortSignal | null } aAbortSignal - An AbortSignal to cancel the operation. 91 * @returns {Promise<{error?: string, ok?: boolean}>} 92 */ 93 async enroll(aExperimentType = "alpha", aAbortSignal = null) { 94 // We abort loading the page if the origion is not allowed. 95 const allowedOrigins = [ 96 new URL(this.guardianEndpoint).origin, 97 new URL(this.fxaOrigin).origin, 98 ]; 99 // If the browser is redirected to one of those urls 100 // we know we're done with the browser. 101 const finalizerURLs = [this.#successURL, this.#enrollmentError]; 102 return await lazy.hiddenBrowserManager.withHiddenBrowser(async browser => { 103 aAbortSignal?.addEventListener("abort", () => { 104 browser.stop(); 105 browser.remove(); 106 throw new Error("aborted"); 107 }); 108 const finalEndpoint = waitUntilURL(browser, url => { 109 const urlObj = new URL(url); 110 if (url === "about:blank") { 111 return false; 112 } 113 if (!allowedOrigins.includes(urlObj.origin)) { 114 browser.stop(); 115 browser.remove(); 116 throw new Error( 117 `URL ${url} with origin ${urlObj.origin} is not allowed.` 118 ); 119 } 120 if ( 121 finalizerURLs.some( 122 finalizer => 123 urlObj.pathname === finalizer.pathname && 124 urlObj.origin === finalizer.origin 125 ) 126 ) { 127 return true; 128 } 129 return false; 130 }); 131 const loginURL = this.#loginURL; 132 loginURL.searchParams.set("experiment", aExperimentType); 133 browser.loadURI(Services.io.newURI(loginURL.href), { 134 // TODO: Make sure this is the right principal to use? 135 triggeringPrincipal: 136 Services.scriptSecurityManager.getSystemPrincipal(), 137 }); 138 139 const result = await finalEndpoint; 140 return GuardianClient._parseGuardianSuccessURL(result); 141 }); 142 } 143 144 static _parseGuardianSuccessURL(aUrl) { 145 if (!aUrl) { 146 return { error: "timeout", ok: false }; 147 } 148 const url = new URL(aUrl); 149 const params = new URLSearchParams(url.search); 150 const error = params.get("error"); 151 if (error) { 152 return { error, ok: false }; 153 } 154 // Otherwise we should have: 155 // - a code in the URL query 156 if (!params.has("code")) { 157 return { error: "missing_code", ok: false }; 158 } 159 return { ok: true }; 160 } 161 162 /** 163 * Fetches a proxy pass from the Guardian service. 164 * 165 * @returns {Promise<{error?: string, status?:number, pass?: ProxyPass}>} Resolves with an object containing either an error string or the proxy pass data and a status code. 166 * Status codes to watch for: 167 * - 200: User is a proxy user and a new pass was fetched 168 * - 403: The FxA was valid but the user is not a proxy user. 169 * - 401: The FxA token was rejected. 170 * - 5xx: Internal guardian error. 171 */ 172 async fetchProxyPass() { 173 const response = await this.withToken(async token => { 174 return await fetch(this.#tokenURL, { 175 method: "GET", 176 cache: "no-cache", 177 headers: { 178 Authorization: `Bearer ${token}`, 179 "Content-Type": "application/json", 180 }, 181 }); 182 }); 183 if (!response) { 184 return { error: "login_needed" }; 185 } 186 const status = response.status; 187 try { 188 const pass = await ProxyPass.fromResponse(response); 189 if (!pass) { 190 return { status, error: "invalid_response" }; 191 } 192 return { pass, status }; 193 } catch (error) { 194 console.error("Error creating ProxyPass:", error); 195 return { status, error: "parse_error" }; 196 } 197 } 198 /** 199 * Fetches the user's entitlement information. 200 * 201 * @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. 202 * 203 * Status codes to watch for: 204 * - 200: User is a proxy user and the entitlement information is available. 205 * - 404: User is not a proxy user, no entitlement information available. 206 * - 401: The FxA token was rejected, probably guardian and fxa mismatch. (i.e guardian-stage and fxa-prod) 207 */ 208 async fetchUserInfo() { 209 const response = await this.withToken(async token => { 210 return fetch(this.#statusURL, { 211 method: "GET", 212 headers: { 213 Authorization: `Bearer ${token}`, 214 "Content-Type": "application/json", 215 }, 216 cache: "no-cache", 217 }); 218 }); 219 if (!response) { 220 return { error: "login_needed" }; 221 } 222 const status = response.status; 223 try { 224 const entitlement = await Entitlement.fromResponse(response); 225 if (!entitlement) { 226 return { status, error: "parse_error" }; 227 } 228 return { 229 status, 230 entitlement, 231 }; 232 } catch (error) { 233 return { status, error: "parse_error" }; 234 } 235 } 236 237 /** This is the URL that will be used to fetch the proxy pass. */ 238 get #tokenURL() { 239 const url = new URL(this.guardianEndpoint); 240 url.pathname = "/api/v1/fpn/token"; 241 return url; 242 } 243 /** This is the URL that will be used to log in to the Guardian service. */ 244 get #loginURL() { 245 const url = new URL(this.guardianEndpoint); 246 url.pathname = "/api/v1/fpn/auth"; 247 return url; 248 } 249 /** This is the URL that the user will be redirected to after a successful enrollment. */ 250 get #successURL() { 251 const url = new URL(this.guardianEndpoint); 252 url.pathname = "/oauth/success"; 253 return url; 254 } 255 /** 256 * This is the URL that the user will be redirected to after a rejected/failed enrollment. 257 * The url will contain an error query parameter with the error message. 258 */ 259 get #enrollmentError() { 260 const url = new URL(this.guardianEndpoint); 261 url.pathname = "/api/v1/fpn/error"; 262 return url; 263 } 264 /** This is the URL that will be used to check the user's proxy status. */ 265 get #statusURL() { 266 const url = new URL(this.guardianEndpoint); 267 url.pathname = "/api/v1/fpn/status"; 268 return url; 269 } 270 guardianEndpoint = ""; 271 } 272 273 /** 274 * A ProxyPass contains a JWT token that can be used to authenticate the proxy service. 275 * It also contains the timestamp until which the token is valid. 276 * The Proxy will reject new connections if the token is not valid anymore. 277 * 278 * Immutable after creation. 279 */ 280 export class ProxyPass extends EventTarget { 281 #body = { 282 /** Not Before */ 283 nbf: 0, 284 /** Expiration */ 285 exp: 0, 286 }; 287 /** 288 * @param {string} token - The JWT to use for authentication. 289 */ 290 constructor(token) { 291 super(); 292 if (typeof token !== "string") { 293 throw new TypeError( 294 "Invalid arguments for ProxyPass constructor, token is not a string" 295 ); 296 } 297 this.token = token; 298 // Contains [header.body.signature] 299 const parts = this.token.split("."); 300 if (parts.length !== 3) { 301 throw new TypeError("Invalid token format"); 302 } 303 try { 304 const body = JSON.parse(atob(parts[1])); 305 if ( 306 !lazy.JsonSchemaValidator.validate(body, ProxyPass.bodySchema).valid 307 ) { 308 throw new TypeError("Token body does not match schema"); 309 } 310 this.#body = body; 311 } catch (error) { 312 throw new TypeError("Invalid token format: " + error.message); 313 } 314 } 315 316 isValid(now = Temporal.Now.instant()) { 317 // If the remaining duration is zero or positive, the pass is still valid. 318 return ( 319 Temporal.Instant.compare(now, this.from) >= 0 && 320 Temporal.Instant.compare(now, this.until) < 0 321 ); 322 } 323 324 shouldRotate(now = Temporal.Now.instant()) { 325 if (!this.isValid(now)) { 326 return true; 327 } 328 return Temporal.Instant.compare(now, this.rotationTimePoint) >= 0; 329 } 330 331 get from() { 332 // nbf is in seconds since epoch 333 return Temporal.Instant.fromEpochMilliseconds(this.#body.nbf * 1000); 334 } 335 336 get until() { 337 // exp is in seconds since epoch 338 return Temporal.Instant.fromEpochMilliseconds(this.#body.exp * 1000); 339 } 340 341 /** 342 * Parses a ProxyPass from a Response object. 343 * 344 * @param {Response} response 345 * @returns {Promise<ProxyPass|null>} A promise that resolves to a ProxyPass instance or null if the response is invalid. 346 */ 347 static async fromResponse(response) { 348 // if the response is not 200 return null 349 if (!response.ok) { 350 console.error( 351 `Failed to fetch proxy pass: ${response.status} ${response.statusText}` 352 ); 353 return null; 354 } 355 356 try { 357 // Parse JSON response 358 const responseData = await response.json(); 359 const token = responseData?.token; 360 361 if (!token || typeof token !== "string") { 362 console.error("Missing or invalid token in response"); 363 return null; 364 } 365 return new ProxyPass(token); 366 } catch (error) { 367 console.error("Error parsing proxy pass response:", error); 368 return null; 369 } 370 } 371 /** 372 * @type {Temporal.Instant} - The Point in time when the token should be rotated. 373 */ 374 get rotationTimePoint() { 375 return this.until.subtract(ProxyPass.ROTATION_TIME); 376 } 377 378 asBearerToken() { 379 return `Bearer ${this.token}`; 380 } 381 // Rotate 10 Minutes from the End Time 382 static ROTATION_TIME = Temporal.Duration.from({ minutes: 10 }); 383 384 static get bodySchema() { 385 return { 386 $schema: "http://json-schema.org/draft-07/schema#", 387 title: "JWT Claims", 388 type: "object", 389 properties: { 390 sub: { 391 type: "string", 392 description: "Subject identifier", 393 }, 394 aud: { 395 type: "string", 396 format: "uri", 397 description: "Audience for which the token is intended", 398 }, 399 iat: { 400 type: "integer", 401 description: "Issued-at time (seconds since Unix epoch)", 402 }, 403 nbf: { 404 type: "integer", 405 description: "Not-before time (seconds since Unix epoch)", 406 }, 407 exp: { 408 type: "integer", 409 description: "Expiration time (seconds since Unix epoch)", 410 }, 411 iss: { 412 type: "string", 413 description: "Issuer identifier", 414 }, 415 }, 416 required: ["sub", "aud", "iat", "nbf", "exp", "iss"], 417 additionalProperties: true, 418 }; 419 } 420 } 421 422 /** 423 * Represents a user's Entitlement for the Proxy Service of Guardian. 424 * 425 * Right now any FxA user can have one entitlement. 426 * If a user has an entitlement, they may access the proxy service. 427 * 428 * Immutable after creation. 429 */ 430 export class Entitlement { 431 /** True if the User may Use the Autostart feature */ 432 autostart = false; 433 /** The date the entitlement was added to the user */ 434 created_at = new Date(); 435 /** True if the User has a limited bandwidth */ 436 limited_bandwidth = false; 437 /** True if the User may Use the location controls */ 438 location_controls = false; 439 /** True if the User has any valid subscription plan to the Mozilla VPN (not firefox VPN) */ 440 subscribed = false; 441 /** The Guardian User ID */ 442 uid = 0; 443 /** True if the User has website inclusion */ 444 website_inclusion = false; 445 446 constructor( 447 args = { 448 autostart: false, 449 created_at: new Date().toISOString(), 450 limited_bandwidth: false, 451 location_controls: false, 452 subscribed: false, 453 uid: 0, 454 website_inclusion: false, 455 } 456 ) { 457 // Ensure it parses to a valid date 458 const parsed = Date.parse(args.created_at); 459 if (isNaN(parsed)) { 460 throw new TypeError("entitlementDate is not a valid date string"); 461 } 462 this.autostart = args.autostart; 463 this.limited_bandwidth = args.limited_bandwidth; 464 this.location_controls = args.location_controls; 465 this.website_inclusion = args.website_inclusion; 466 this.subscribed = args.subscribed; 467 this.uid = args.uid; 468 this.created_at = parsed; 469 Object.freeze(this); 470 } 471 static fromResponse(response) { 472 // if the response is not 200 return null 473 if (!response.ok) { 474 return null; 475 } 476 return response.json().then(data => { 477 const result = lazy.JsonSchemaValidator.validate( 478 data, 479 Entitlement.schema 480 ); 481 if (!result.valid) { 482 return null; 483 } 484 return new Entitlement(data); 485 }); 486 } 487 488 static get schema() { 489 return { 490 $schema: "http://json-schema.org/draft-07/schema#", 491 title: "Entitlement", 492 type: "object", 493 properties: { 494 autostart: { 495 type: "boolean", 496 description: "True if the User may Use the Autostart feature", 497 }, 498 created_at: { 499 type: "string", 500 description: "The date the entitlement was added to the user", 501 format: "date-time", // ISO 8601 502 pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$", 503 }, 504 limited_bandwidth: { 505 type: "boolean", 506 }, 507 location_controls: { 508 type: "boolean", 509 }, 510 subscribed: { 511 type: "boolean", 512 }, 513 uid: { 514 type: "integer", 515 }, 516 website_inclusion: { 517 type: "boolean", 518 }, 519 }, 520 required: [ 521 "autostart", 522 "created_at", 523 "limited_bandwidth", 524 "location_controls", 525 "subscribed", 526 "uid", 527 "website_inclusion", 528 ], 529 additionalProperties: true, 530 }; 531 } 532 } 533 534 /** 535 * Maps the Guardian service endpoint to the public OAuth client ID. 536 */ 537 const CLIENT_ID_MAP = { 538 "http://localhost:3000": "6089c54fdc970aed", 539 "https://guardian-dev.herokuapp.com": "64ef9b544a31bca8", 540 "https://stage.guardian.nonprod.cloudops.mozgcp.net": "e6eb0d1e856335fc", 541 "https://fpn.firefox.com": "e6eb0d1e856335fc", 542 "https://vpn.mozilla.org": "e6eb0d1e856335fc", 543 }; 544 545 /** 546 * Adds a strong reference to keep listeners alive until 547 * we're done with it. 548 * (From kungFuDeathGrip in XPCShellContentUtils.sys.mjs) 549 */ 550 const listeners = new Set(); 551 552 /** 553 * Waits for a specific URL to be loaded in the browser. 554 * 555 * @param {*} browser - The browser instance to listen for URL changes. 556 * @param {(location: string) => boolean} predicate - A function that returns true if the location matches the desired URL. 557 * @returns {Promise<string>} A promise that resolves to the matching URL. 558 */ 559 async function waitUntilURL(browser, predicate) { 560 const prom = Promise.withResolvers(); 561 const done = false; 562 const check = arg => { 563 if (done) { 564 return; 565 } 566 if (predicate(arg)) { 567 listeners.delete(listener); 568 browser.removeProgressListener(listener); 569 prom.resolve(arg); 570 } 571 }; 572 const listener = { 573 QueryInterface: ChromeUtils.generateQI([ 574 "nsIWebProgressListener", 575 "nsISupportsWeakReference", 576 ]), 577 578 // Runs the check after the document has stopped loading. 579 onStateChange(webProgress, request, stateFlags, status) { 580 request.QueryInterface(Ci.nsIChannel); 581 582 if ( 583 webProgress.isTopLevel && 584 stateFlags & Ci.nsIWebProgressListener.STATE_STOP && 585 status !== Cr.NS_BINDING_ABORTED 586 ) { 587 check(request.URI?.spec); 588 } 589 }, 590 591 // Unused callbacks we still need to implement: 592 onLocationChange() {}, 593 onProgressChange() {}, 594 onStatusChange(_, request, status) { 595 if (Components.isSuccessCode(status)) { 596 return; 597 } 598 try { 599 const url = request.QueryInterface(Ci.nsIChannel).URI.spec; 600 check(url); 601 } catch (ex) {} 602 }, 603 onSecurityChange() {}, 604 onContentBlockingEvent() {}, 605 }; 606 listeners.add(listener); 607 browser.addProgressListener(listener, Ci.nsIWebProgress.NOTIFY_STATE_WINDOW); 608 const url = await prom.promise; 609 return url; 610 } 611 612 let gConfig = { 613 /** 614 * Executes the callback with an FxA token and returns its result. 615 * Destroys the token after use. 616 * 617 * @template T 618 * @param {(token: string) => T|Promise<T>} cb 619 * @returns {Promise<T|null>} 620 */ 621 withToken: async cb => { 622 const token = await lazy.fxAccounts.getOAuthToken({ 623 scope: ["profile", "https://identity.mozilla.com/apps/vpn"], 624 }); 625 if (!token) { 626 return null; 627 } 628 const res = await cb(token); 629 lazy.fxAccounts.removeCachedOAuthToken({ 630 token, 631 }); 632 return res; 633 }, 634 guardianEndpoint: "", 635 fxaOrigin: "", 636 }; 637 XPCOMUtils.defineLazyPreferenceGetter( 638 gConfig, 639 "guardianEndpoint", 640 "browser.ipProtection.guardian.endpoint", 641 "https://vpn.mozilla.com" 642 ); 643 XPCOMUtils.defineLazyPreferenceGetter( 644 gConfig, 645 "fxaOrigin", 646 "identity.fxaccounts.remote.root" 647 );