tor-browser

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

test_oauth_flow.js (13217B)


      1 /* Any copyright is dedicated to the Public Domain.
      2 * http://creativecommons.org/publicdomain/zero/1.0/ */
      3 
      4 /* global crypto */
      5 
      6 "use strict";
      7 
      8 const {
      9  FxAccountsOAuth,
     10  ERROR_INVALID_SCOPES,
     11  ERROR_INVALID_SCOPED_KEYS,
     12  ERROR_INVALID_STATE,
     13  ERROR_OAUTH_FLOW_ABANDONED,
     14 } = ChromeUtils.importESModule(
     15  "resource://gre/modules/FxAccountsOAuth.sys.mjs"
     16 );
     17 
     18 const { SCOPE_PROFILE, OAUTH_CLIENT_ID } = ChromeUtils.importESModule(
     19  "resource://gre/modules/FxAccountsCommon.sys.mjs"
     20 );
     21 
     22 ChromeUtils.defineESModuleGetters(this, {
     23  jwcrypto: "moz-src:///services/crypto/modules/jwcrypto.sys.mjs",
     24  FxAccountsKeys: "resource://gre/modules/FxAccountsKeys.sys.mjs",
     25 });
     26 
     27 initTestLogging("Trace");
     28 
     29 add_task(function test_begin_oauth_flow() {
     30  const oauth = new FxAccountsOAuth();
     31  add_task(async function test_begin_oauth_flow_invalid_scopes() {
     32    try {
     33      await oauth.beginOAuthFlow("foo,fi,fum", "foo");
     34      Assert.fail("Should have thrown error, scopes must be an array");
     35    } catch (e) {
     36      Assert.equal(e.message, ERROR_INVALID_SCOPES);
     37    }
     38    try {
     39      await oauth.beginOAuthFlow(["not-a-real-scope", SCOPE_PROFILE]);
     40      Assert.fail("Should have thrown an error, must use a valid scope");
     41    } catch (e) {
     42      Assert.equal(e.message, ERROR_INVALID_SCOPES);
     43    }
     44  });
     45  add_task(async function test_begin_oauth_flow_ok() {
     46    const scopes = [SCOPE_PROFILE, SCOPE_APP_SYNC];
     47    const queryParams = await oauth.beginOAuthFlow(scopes);
     48 
     49    // First verify default query parameters
     50    Assert.equal(queryParams.client_id, OAUTH_CLIENT_ID);
     51    Assert.equal(queryParams.action, "email");
     52    Assert.equal(queryParams.response_type, "code");
     53    Assert.equal(queryParams.access_type, "offline");
     54    Assert.equal(queryParams.scope, [SCOPE_PROFILE, SCOPE_APP_SYNC].join(" "));
     55 
     56    // Then, we verify that the state is a valid Base64 value
     57    const state = queryParams.state;
     58    ChromeUtils.base64URLDecode(state, { padding: "reject" });
     59 
     60    // Then, we verify that the codeVerifier, can be used to verify the code_challenge
     61    const code_challenge = queryParams.code_challenge;
     62    Assert.equal(queryParams.code_challenge_method, "S256");
     63    const oauthFlow = oauth.getFlow(state);
     64    const codeVerifierB64 = oauthFlow.verifier;
     65    const expectedChallenge = await crypto.subtle.digest(
     66      "SHA-256",
     67      new TextEncoder().encode(codeVerifierB64)
     68    );
     69    const expectedChallengeB64 = ChromeUtils.base64URLEncode(
     70      expectedChallenge,
     71      { pad: false }
     72    );
     73    Assert.equal(expectedChallengeB64, code_challenge);
     74 
     75    // Then, we verify that something encrypted with the `keys_jwk`, can be decrypted using the private key
     76    const keysJwk = queryParams.keys_jwk;
     77    const decodedKeysJwk = JSON.parse(
     78      new TextDecoder().decode(
     79        ChromeUtils.base64URLDecode(keysJwk, { padding: "reject" })
     80      )
     81    );
     82    const plaintext = "text to be encrypted and decrypted!";
     83    delete decodedKeysJwk.key_ops;
     84    const jwe = await jwcrypto.generateJWE(
     85      decodedKeysJwk,
     86      new TextEncoder().encode(plaintext)
     87    );
     88    const privateKey = oauthFlow.key;
     89    const decrypted = await jwcrypto.decryptJWE(jwe, privateKey);
     90    Assert.equal(new TextDecoder().decode(decrypted), plaintext);
     91 
     92    // Finally, we verify that we stored the requested scopes
     93    Assert.deepEqual(oauthFlow.requestedScopes, scopes.join(" "));
     94  });
     95 });
     96 
     97 add_task(function test_complete_oauth_flow() {
     98  add_task(async function test_invalid_state() {
     99    const oauth = new FxAccountsOAuth();
    100    const code = "foo";
    101    const state = "bar";
    102    const sessionToken = "01abcef12";
    103    try {
    104      await oauth.completeOAuthFlow(sessionToken, code, state);
    105      Assert.fail("Should have thrown an error");
    106    } catch (err) {
    107      Assert.equal(err.message, ERROR_INVALID_STATE);
    108    }
    109  });
    110  add_task(async function test_sync_scope_not_authorized() {
    111    const fxaClient = {
    112      oauthToken: () =>
    113        Promise.resolve({
    114          access_token: "access_token",
    115          refresh_token: "refresh_token",
    116          // Note that the scope does not include the sync scope
    117          scope: SCOPE_PROFILE,
    118        }),
    119    };
    120    const oauth = new FxAccountsOAuth(fxaClient);
    121    const scopes = [SCOPE_PROFILE, SCOPE_APP_SYNC];
    122    const sessionToken = "01abcef12";
    123    const queryParams = await oauth.beginOAuthFlow(scopes);
    124    try {
    125      await oauth.completeOAuthFlow(sessionToken, "foo", queryParams.state);
    126    } catch (err) {
    127      Assert.fail(
    128        "We should throw if we don't receive requested scope, we skip sync setup"
    129      );
    130    }
    131  });
    132  add_task(async function test_jwe_not_returned() {
    133    const scopes = [SCOPE_PROFILE, SCOPE_APP_SYNC];
    134    const fxaClient = {
    135      oauthToken: () =>
    136        Promise.resolve({
    137          access_token: "access_token",
    138          refresh_token: "refresh_token",
    139          scope: scopes.join(" "),
    140        }),
    141    };
    142    const oauth = new FxAccountsOAuth(fxaClient);
    143    const queryParams = await oauth.beginOAuthFlow(scopes);
    144    const sessionToken = "01abcef12";
    145    try {
    146      await oauth.completeOAuthFlow(sessionToken, "foo", queryParams.state);
    147    } catch (err) {
    148      Assert.fail(
    149        "Should not have thrown an error because we didn't get back a keys_jwe,we instead skip sync setup"
    150      );
    151    }
    152  });
    153  add_task(async function test_complete_oauth_ok() {
    154    // First, we initialize some fake values we would typically get
    155    // from outside our system
    156    const scopes = [SCOPE_PROFILE, SCOPE_APP_SYNC];
    157    const oauthCode = "fake oauth code";
    158    const sessionToken = "01abcef12";
    159    const plainTextScopedKeys = {
    160      [SCOPE_APP_SYNC]: {
    161        kty: "oct",
    162        kid: "1510726318123-IqQv4onc7VcVE1kTQkyyOw",
    163        k: "DW_ll5GwX6SJ5GPqJVAuMUP2t6kDqhUulc2cbt26xbTcaKGQl-9l29FHAQ7kUiJETma4s9fIpEHrt909zgFang",
    164        scope: SCOPE_APP_SYNC,
    165      },
    166    };
    167    const fakeAccessToken = "fake access token";
    168    const fakeRefreshToken = "fake refresh token";
    169    // Then, we initialize a fake http client, we'll add our fake oauthToken call
    170    // once we have started the oauth flow (so we have the public keys!)
    171    const fxaClient = {};
    172    const fxaKeys = new FxAccountsKeys(null);
    173    // Then, we initialize our oauth object with the given client and begin a new flow
    174    const oauth = new FxAccountsOAuth(fxaClient, fxaKeys);
    175    const queryParams = await oauth.beginOAuthFlow(scopes);
    176    // Now that we have the public keys in `keys_jwk`, we use it to generate a JWE
    177    // representing our scoped keys
    178    const keysJwk = queryParams.keys_jwk;
    179    const decodedKeysJwk = JSON.parse(
    180      new TextDecoder().decode(
    181        ChromeUtils.base64URLDecode(keysJwk, { padding: "reject" })
    182      )
    183    );
    184    delete decodedKeysJwk.key_ops;
    185    const jwe = await jwcrypto.generateJWE(
    186      decodedKeysJwk,
    187      new TextEncoder().encode(JSON.stringify(plainTextScopedKeys))
    188    );
    189    // We also grab the stored PKCE verifier that the oauth object stored internally
    190    // to verify that we correctly send it as a part of our HTTP request
    191    const storedVerifier = oauth.getFlow(queryParams.state).verifier;
    192 
    193    // To test what happens when more than one flow is completed simulatniously
    194    // We mimic a slow network call on the first oauthToken call and let the second
    195    // one win
    196    let callCount = 0;
    197    let slowResolve;
    198    const resolveFn = (payload, resolve) => {
    199      if (callCount === 1) {
    200        // This is the second call
    201        // lets resolve it so the second call wins
    202        resolve(payload);
    203      } else {
    204        callCount += 1;
    205        // This is the first call, let store our resolve function for later
    206        // it will be resolved once the fast flow is fully completed
    207        slowResolve = () => resolve(payload);
    208      }
    209    };
    210 
    211    // Now we initialize our mock of the HTTP request, it verifies we passed in all the correct
    212    // parameters and returns what we'd expect a healthy HTTP Response would look like
    213    fxaClient.oauthToken = (sessionTokenHex, code, verifier, clientId) => {
    214      Assert.equal(sessionTokenHex, sessionToken);
    215      Assert.equal(code, oauthCode);
    216      Assert.equal(verifier, storedVerifier);
    217      Assert.equal(clientId, queryParams.client_id);
    218      const response = {
    219        access_token: fakeAccessToken,
    220        refresh_token: fakeRefreshToken,
    221        scope: scopes.join(" "),
    222        keys_jwe: jwe,
    223      };
    224      return new Promise(resolve => {
    225        resolveFn(response, resolve);
    226      });
    227    };
    228 
    229    // Then, we call the completeOAuthFlow function, and get back our access token,
    230    // refresh token and scopedKeys
    231 
    232    // To test what happens when multiple flows race, we create two flows,
    233    // A slow one that will start first, but finish last
    234    // And a fast one that will beat the slow one
    235    const firstCompleteOAuthFlow = oauth
    236      .completeOAuthFlow(sessionToken, oauthCode, queryParams.state)
    237      .then(res => {
    238        // To mimic the slow network connection on the slowCompleteOAuthFlow
    239        // We resume the slow completeOAuthFlow once this one is complete
    240        slowResolve();
    241        return res;
    242      });
    243    const secondCompleteOAuthFlow = oauth
    244      .completeOAuthFlow(sessionToken, oauthCode, queryParams.state)
    245      .then(res => {
    246        // since we can't fully gaurentee which oauth flow finishes first, we also resolve here
    247        slowResolve();
    248        return res;
    249      });
    250 
    251    const { accessToken, refreshToken, scopedKeys } = await Promise.allSettled([
    252      firstCompleteOAuthFlow,
    253      secondCompleteOAuthFlow,
    254    ]).then(results => {
    255      let fast;
    256      let slow;
    257      for (const result of results) {
    258        if (result.status === "fulfilled") {
    259          fast = result.value;
    260        } else {
    261          slow = result.reason;
    262        }
    263      }
    264      // We make sure that we indeed have one slow flow that lost
    265      Assert.equal(slow.message, ERROR_OAUTH_FLOW_ABANDONED);
    266      return fast;
    267    });
    268 
    269    Assert.equal(accessToken, fakeAccessToken);
    270    Assert.equal(refreshToken, fakeRefreshToken);
    271    Assert.deepEqual(scopedKeys, plainTextScopedKeys);
    272 
    273    // Finally, we verify that all stored flows were cleared
    274    Assert.equal(oauth.numOfFlows(), 0);
    275  });
    276  add_task(async function test_complete_oauth_invalid_scoped_keys() {
    277    // First, we initialize some fake values we would typically get
    278    // from outside our system
    279    const scopes = [SCOPE_PROFILE, SCOPE_APP_SYNC];
    280    const oauthCode = "fake oauth code";
    281    const sessionToken = "01abcef12";
    282    const invalidScopedKeys = {
    283      [SCOPE_APP_SYNC]: {
    284        // ====== This is an invalid key type! Should be "oct", so we will raise an error once we realize
    285        kty: "EC",
    286        kid: "1510726318123-IqQv4onc7VcVE1kTQkyyOw",
    287        k: "DW_ll5GwX6SJ5GPqJVAuMUP2t6kDqhUulc2cbt26xbTcaKGQl-9l29FHAQ7kUiJETma4s9fIpEHrt909zgFang",
    288        scope: SCOPE_APP_SYNC,
    289      },
    290    };
    291    const fakeAccessToken = "fake access token";
    292    const fakeRefreshToken = "fake refresh token";
    293    // Then, we initialize a fake http client, we'll add our fake oauthToken call
    294    // once we have started the oauth flow (so we have the public keys!)
    295    const fxaClient = {};
    296    const fxaKeys = new FxAccountsKeys(null);
    297    // Then, we initialize our oauth object with the given client and begin a new flow
    298    const oauth = new FxAccountsOAuth(fxaClient, fxaKeys);
    299    const queryParams = await oauth.beginOAuthFlow(scopes);
    300    // Now that we have the public keys in `keys_jwk`, we use it to generate a JWE
    301    // representing our scoped keys
    302    const keysJwk = queryParams.keys_jwk;
    303    const decodedKeysJwk = JSON.parse(
    304      new TextDecoder().decode(
    305        ChromeUtils.base64URLDecode(keysJwk, { padding: "reject" })
    306      )
    307    );
    308    delete decodedKeysJwk.key_ops;
    309    const jwe = await jwcrypto.generateJWE(
    310      decodedKeysJwk,
    311      new TextEncoder().encode(JSON.stringify(invalidScopedKeys))
    312    );
    313    // We also grab the stored PKCE verifier that the oauth object stored internally
    314    // to verify that we correctly send it as a part of our HTTP request
    315    const storedVerifier = oauth.getFlow(queryParams.state).verifier;
    316 
    317    // Now we initialize our mock of the HTTP request, it verifies we passed in all the correct
    318    // parameters and returns what we'd expect a healthy HTTP Response would look like
    319    fxaClient.oauthToken = (sessionTokenHex, code, verifier, clientId) => {
    320      Assert.equal(sessionTokenHex, sessionToken);
    321      Assert.equal(code, oauthCode);
    322      Assert.equal(verifier, storedVerifier);
    323      Assert.equal(clientId, queryParams.client_id);
    324      const response = {
    325        access_token: fakeAccessToken,
    326        refresh_token: fakeRefreshToken,
    327        scope: scopes.join(" "),
    328        keys_jwe: jwe,
    329      };
    330      return Promise.resolve(response);
    331    };
    332 
    333    // Then, we call the completeOAuthFlow function, and get back our access token,
    334    // refresh token and scopedKeys
    335    try {
    336      await oauth.completeOAuthFlow(sessionToken, oauthCode, queryParams.state);
    337      Assert.fail(
    338        "Should have thrown an error because the scoped keys are not valid"
    339      );
    340    } catch (err) {
    341      Assert.equal(err.message, ERROR_INVALID_SCOPED_KEYS);
    342    }
    343  });
    344 });