tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

FxAccountsOAuth.sys.mjs (9067B)


      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 const lazy = {};
      6 
      7 ChromeUtils.defineESModuleGetters(lazy, {
      8  jwcrypto: "moz-src:///services/crypto/modules/jwcrypto.sys.mjs",
      9 });
     10 
     11 import {
     12  OAUTH_CLIENT_ID,
     13  SCOPE_PROFILE,
     14  SCOPE_PROFILE_WRITE,
     15  SCOPE_APP_SYNC,
     16  log,
     17 } from "resource://gre/modules/FxAccountsCommon.sys.mjs";
     18 
     19 const VALID_SCOPES = [SCOPE_PROFILE, SCOPE_PROFILE_WRITE, SCOPE_APP_SYNC];
     20 
     21 export const ERROR_INVALID_SCOPES = "INVALID_SCOPES";
     22 export const ERROR_INVALID_STATE = "INVALID_STATE";
     23 export const ERROR_SYNC_SCOPE_NOT_GRANTED = "ERROR_SYNC_SCOPE_NOT_GRANTED";
     24 export const ERROR_NO_KEYS_JWE = "ERROR_NO_KEYS_JWE";
     25 export const ERROR_OAUTH_FLOW_ABANDONED = "ERROR_OAUTH_FLOW_ABANDONED";
     26 export const ERROR_INVALID_SCOPED_KEYS = "ERROR_INVALID_SCOPED_KEYS";
     27 
     28 /**
     29 * Handles all logic and state related to initializing, and completing OAuth flows
     30 * with FxA
     31 * It's possible to start multiple OAuth flow, but only one can be completed, and once one flow is completed
     32 * all the other in-flight flows will be concluded, and attempting to complete those flows will result in errors.
     33 */
     34 export class FxAccountsOAuth {
     35  #flow;
     36  #fxaClient;
     37  #fxaKeys;
     38  /**
     39   * Creates a new FxAccountsOAuth
     40   *
     41   * @param {object} fxaClient: The fxa client used to send http request to the oauth server
     42   */
     43  constructor(fxaClient, fxaKeys) {
     44    this.#flow = {};
     45    this.#fxaClient = fxaClient;
     46    this.#fxaKeys = fxaKeys;
     47  }
     48 
     49  /**
     50   * Stores a flow in-memory
     51   *
     52   * @param { string } state: A base-64 URL-safe string represnting a random value created at the start of the flow
     53   * @param {object} value: The data needed to complete a flow, once the oauth code is available.
     54   * in practice, `value` is:
     55   *  - `verifier`: A base=64 URL-safe string representing the PKCE code verifier
     56   *  - `key`: The private key need to decrypt the JWE we recieve from the auth server
     57   *  - `requestedScopes`: The scopes the caller requested, meant to be compared against the scopes the server authorized
     58   */
     59  addFlow(state, value) {
     60    this.#flow[state] = value;
     61  }
     62 
     63  /**
     64   * Clears all started flows
     65   */
     66  clearAllFlows() {
     67    this.#flow = {};
     68  }
     69 
     70  /**
     71   * Gets a stored flow
     72   *
     73   * @param { string } state: The base-64 URL-safe state string that was created at the start of the flow
     74   * @returns {object}: The values initially stored when startign th eoauth flow
     75   * in practice, the return value is:
     76   *  - `verifier`: A base=64 URL-safe string representing the PKCE code verifier
     77   *  - `key`: The private key need to decrypt the JWE we recieve from the auth server
     78   *  - ``requestedScopes`: The scopes the caller requested, meant to be compared against the scopes the server authorized
     79   */
     80  getFlow(state) {
     81    return this.#flow[state];
     82  }
     83 
     84  /* Returns the number of flows, used by tests
     85   *
     86   */
     87  numOfFlows() {
     88    return Object.keys(this.#flow).length;
     89  }
     90 
     91  /**
     92   * Begins an OAuth flow, to be completed with a an OAuth code and state.
     93   *
     94   * This function stores needed information to complete the flow. You must call `completeOAuthFlow`
     95   * on the same instance of `FxAccountsOAuth`, otherwise the completing of the oauth flow will fail.
     96   *
     97   * @param { string[] } scopes: The OAuth scopes the client should request from FxA
     98   *
     99   * @returns {object}: Returns an object representing the query parameters that should be
    100   *     added to the FxA authorization URL to initialize an oAuth flow.
    101   *     In practice, the query parameters are:
    102   *       - `client_id`: The OAuth client ID for Firefox Desktop
    103   *       - `scope`: The scopes given by the caller, space seperated
    104   *       - `action`: This will always be `email`
    105   *       - `response_type`: This will always be `code`
    106   *       - `access_type`: This will always be `offline`
    107   *       - `state`: A URL-safe base-64 string randomly generated
    108   *       - `code_challenge`: A URL-safe base-64 string representing the PKCE challenge
    109   *       - `code_challenge_method`: This will always be `S256`
    110   *          For more informatio about PKCE, read https://datatracker.ietf.org/doc/html/rfc7636
    111   *       - `keys_jwk`: A URL-safe base-64 representing a JWK to be used as a public key by the server
    112   *          to generate a JWE
    113   */
    114  async beginOAuthFlow(scopes) {
    115    if (
    116      !Array.isArray(scopes) ||
    117      scopes.some(scope => !VALID_SCOPES.includes(scope))
    118    ) {
    119      throw new Error(ERROR_INVALID_SCOPES);
    120    }
    121    const queryParams = {
    122      client_id: OAUTH_CLIENT_ID,
    123      action: "email",
    124      response_type: "code",
    125      access_type: "offline",
    126      scope: scopes.join(" "),
    127    };
    128 
    129    // Generate a random, 16 byte value to represent a state that we verify
    130    // once we complete the oauth flow, to ensure that we only conclude
    131    // an oauth flow that we started
    132    const state = new Uint8Array(16);
    133    crypto.getRandomValues(state);
    134    const stateB64 = ChromeUtils.base64URLEncode(state, { pad: false });
    135    queryParams.state = stateB64;
    136 
    137    // Generate a 43 byte code verifier for PKCE, in accordance with
    138    // https://datatracker.ietf.org/doc/html/rfc7636#section-7.1 which recommends a
    139    // 43-octet URL safe string
    140    // The byte array is 32 bytes
    141    const codeVerifier = new Uint8Array(32);
    142    crypto.getRandomValues(codeVerifier);
    143    // When base64 encoded, it is 43 bytes
    144    const codeVerifierB64 = ChromeUtils.base64URLEncode(codeVerifier, {
    145      pad: false,
    146    });
    147    const challenge = await crypto.subtle.digest(
    148      "SHA-256",
    149      new TextEncoder().encode(codeVerifierB64)
    150    );
    151    const challengeB64 = ChromeUtils.base64URLEncode(challenge, { pad: false });
    152    queryParams.code_challenge = challengeB64;
    153    queryParams.code_challenge_method = "S256";
    154 
    155    // Generate a public, private key pair to be used during the oauth flow
    156    // to encrypt scoped-keys as they roundtrip through the auth server
    157    const ECDH_KEY = { name: "ECDH", namedCurve: "P-256" };
    158    const key = await crypto.subtle.generateKey(ECDH_KEY, false, ["deriveKey"]);
    159    const publicKey = await crypto.subtle.exportKey("jwk", key.publicKey);
    160    const privateKey = key.privateKey;
    161 
    162    // We encode the public key as URL-safe base64 to be included in the query parameters
    163    const encodedPublicKey = ChromeUtils.base64URLEncode(
    164      new TextEncoder().encode(JSON.stringify(publicKey)),
    165      { pad: false }
    166    );
    167    queryParams.keys_jwk = encodedPublicKey;
    168 
    169    // We store the state in-memory, to verify once the oauth flow is completed
    170    this.addFlow(stateB64, {
    171      key: privateKey,
    172      verifier: codeVerifierB64,
    173      requestedScopes: scopes.join(" "),
    174    });
    175    return queryParams;
    176  }
    177 
    178  /**
    179   * Completes an OAuth flow and invalidates any other ongoing flows
    180   *
    181   * @param { string } sessionTokenHex: The session token encoded in hexadecimal
    182   * @param { string } code: OAuth authorization code provided by running an OAuth flow
    183   * @param { string } state: The state first provided by `beginOAuthFlow`, then roundtripped through the server
    184   *
    185   * @returns {object}: Returns an object representing the result of completing the oauth flow.
    186   *   The object includes the following:
    187   *     - 'scopedKeys': The encryption keys provided by the server, already decrypted
    188   *     - 'refreshToken': The refresh token provided by the server
    189   *     - 'accessToken': The access token provided by the server
    190   */
    191  async completeOAuthFlow(sessionTokenHex, code, state) {
    192    const flow = this.getFlow(state);
    193    if (!flow) {
    194      throw new Error(ERROR_INVALID_STATE);
    195    }
    196    const { key, verifier, requestedScopes } = flow;
    197    const { keys_jwe, refresh_token, access_token, scope } =
    198      await this.#fxaClient.oauthToken(
    199        sessionTokenHex,
    200        code,
    201        verifier,
    202        OAUTH_CLIENT_ID
    203      );
    204    const requestedSync = requestedScopes.includes(SCOPE_APP_SYNC);
    205    const grantedSync = scope.includes(SCOPE_APP_SYNC);
    206    // This is not necessarily unexpected as the user could be using
    207    // third-party auth but sent the sync scope, we shouldn't error here
    208    if (requestedSync && !grantedSync) {
    209      log.info("Requested Sync scope but was not granted sync!");
    210    }
    211    let scopedKeys;
    212    if (keys_jwe) {
    213      scopedKeys = JSON.parse(
    214        new TextDecoder().decode(await lazy.jwcrypto.decryptJWE(keys_jwe, key))
    215      );
    216      if (!this.#fxaKeys.validScopedKeys(scopedKeys)) {
    217        throw new Error(ERROR_INVALID_SCOPED_KEYS);
    218      }
    219    }
    220 
    221    // We make sure no other flow snuck in, and completed before we did
    222    if (!this.getFlow(state)) {
    223      throw new Error(ERROR_OAUTH_FLOW_ABANDONED);
    224    }
    225 
    226    // Clear all flows, so any in-flight or future flows trigger an error as the browser
    227    // would have been signed in
    228    this.clearAllFlows();
    229    return {
    230      scopedKeys,
    231      refreshToken: refresh_token,
    232      accessToken: access_token,
    233    };
    234  }
    235 }