summaryrefslogtreecommitdiffstats
path: root/services/fxaccounts/tests/xpcshell/test_oauth_flow.js
blob: ef5102ae17ab3cc2eb57572185d88f23dd1c5096 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
/* Any copyright is dedicated to the Public Domain.
 * http://creativecommons.org/publicdomain/zero/1.0/ */

/* global crypto */

"use strict";

const {
  FxAccountsOAuth,
  ERROR_INVALID_SCOPES,
  ERROR_INVALID_STATE,
  ERROR_SYNC_SCOPE_NOT_GRANTED,
  ERROR_NO_KEYS_JWE,
  ERROR_OAUTH_FLOW_ABANDONED,
} = ChromeUtils.importESModule(
  "resource://gre/modules/FxAccountsOAuth.sys.mjs"
);

const { SCOPE_PROFILE, FX_OAUTH_CLIENT_ID } = ChromeUtils.importESModule(
  "resource://gre/modules/FxAccountsCommon.sys.mjs"
);

ChromeUtils.defineESModuleGetters(this, {
  jwcrypto: "resource://services-crypto/jwcrypto.sys.mjs",
});

initTestLogging("Trace");

add_task(function test_begin_oauth_flow() {
  const oauth = new FxAccountsOAuth();
  add_task(async function test_begin_oauth_flow_invalid_scopes() {
    try {
      await oauth.beginOAuthFlow("foo,fi,fum", "foo");
      Assert.fail("Should have thrown error, scopes must be an array");
    } catch (e) {
      Assert.equal(e.message, ERROR_INVALID_SCOPES);
    }
    try {
      await oauth.beginOAuthFlow(["not-a-real-scope", SCOPE_PROFILE]);
      Assert.fail("Should have thrown an error, must use a valid scope");
    } catch (e) {
      Assert.equal(e.message, ERROR_INVALID_SCOPES);
    }
  });
  add_task(async function test_begin_oauth_flow_ok() {
    const scopes = [SCOPE_PROFILE, SCOPE_OLD_SYNC];
    const queryParams = await oauth.beginOAuthFlow(scopes);

    // First verify default query parameters
    Assert.equal(queryParams.client_id, FX_OAUTH_CLIENT_ID);
    Assert.equal(queryParams.action, "email");
    Assert.equal(queryParams.response_type, "code");
    Assert.equal(queryParams.access_type, "offline");
    Assert.equal(queryParams.scope, [SCOPE_PROFILE, SCOPE_OLD_SYNC].join(" "));

    // Then, we verify that the state is a valid Base64 value
    const state = queryParams.state;
    ChromeUtils.base64URLDecode(state, { padding: "reject" });

    // Then, we verify that the codeVerifier, can be used to verify the code_challenge
    const code_challenge = queryParams.code_challenge;
    Assert.equal(queryParams.code_challenge_method, "S256");
    const oauthFlow = oauth.getFlow(state);
    const codeVerifierB64 = oauthFlow.verifier;
    const expectedChallenge = await crypto.subtle.digest(
      "SHA-256",
      new TextEncoder().encode(codeVerifierB64)
    );
    const expectedChallengeB64 = ChromeUtils.base64URLEncode(
      expectedChallenge,
      { pad: false }
    );
    Assert.equal(expectedChallengeB64, code_challenge);

    // Then, we verify that something encrypted with the `keys_jwk`, can be decrypted using the private key
    const keysJwk = queryParams.keys_jwk;
    const decodedKeysJwk = JSON.parse(
      new TextDecoder().decode(
        ChromeUtils.base64URLDecode(keysJwk, { padding: "reject" })
      )
    );
    const plaintext = "text to be encrypted and decrypted!";
    delete decodedKeysJwk.key_ops;
    const jwe = await jwcrypto.generateJWE(
      decodedKeysJwk,
      new TextEncoder().encode(plaintext)
    );
    const privateKey = oauthFlow.key;
    const decrypted = await jwcrypto.decryptJWE(jwe, privateKey);
    Assert.equal(new TextDecoder().decode(decrypted), plaintext);

    // Finally, we verify that we stored the requested scopes
    Assert.deepEqual(oauthFlow.requestedScopes, scopes.join(" "));
  });
});

add_task(function test_complete_oauth_flow() {
  add_task(async function test_invalid_state() {
    const oauth = new FxAccountsOAuth();
    const code = "foo";
    const state = "bar";
    const sessionToken = "01abcef12";
    try {
      await oauth.completeOAuthFlow(sessionToken, code, state);
      Assert.fail("Should have thrown an error");
    } catch (err) {
      Assert.equal(err.message, ERROR_INVALID_STATE);
    }
  });
  add_task(async function test_sync_scope_not_authorized() {
    const fxaClient = {
      oauthToken: () =>
        Promise.resolve({
          access_token: "access_token",
          refresh_token: "refresh_token",
          // Note that the scope does not include the sync scope
          scope: SCOPE_PROFILE,
        }),
    };
    const oauth = new FxAccountsOAuth(fxaClient);
    const scopes = [SCOPE_PROFILE, SCOPE_OLD_SYNC];
    const sessionToken = "01abcef12";
    const queryParams = await oauth.beginOAuthFlow(scopes);
    try {
      await oauth.completeOAuthFlow(sessionToken, "foo", queryParams.state);
      Assert.fail(
        "Should have thrown an error because the sync scope was not authorized"
      );
    } catch (err) {
      Assert.equal(err.message, ERROR_SYNC_SCOPE_NOT_GRANTED);
    }
  });
  add_task(async function test_jwe_not_returned() {
    const scopes = [SCOPE_PROFILE, SCOPE_OLD_SYNC];
    const fxaClient = {
      oauthToken: () =>
        Promise.resolve({
          access_token: "access_token",
          refresh_token: "refresh_token",
          scope: scopes.join(" "),
        }),
    };
    const oauth = new FxAccountsOAuth(fxaClient);
    const queryParams = await oauth.beginOAuthFlow(scopes);
    const sessionToken = "01abcef12";
    try {
      await oauth.completeOAuthFlow(sessionToken, "foo", queryParams.state);
      Assert.fail(
        "Should have thrown an error because we didn't get back a keys_nwe"
      );
    } catch (err) {
      Assert.equal(err.message, ERROR_NO_KEYS_JWE);
    }
  });
  add_task(async function test_complete_oauth_ok() {
    // First, we initialize some fake values we would typically get
    // from outside our system
    const scopes = [SCOPE_PROFILE, SCOPE_OLD_SYNC];
    const oauthCode = "fake oauth code";
    const sessionToken = "01abcef12";
    const plainTextScopedKeys = {
      kid: "fake key id",
      k: "fake key",
      kty: "oct",
    };
    const fakeAccessToken = "fake access token";
    const fakeRefreshToken = "fake refresh token";
    // Then, we initialize a fake http client, we'll add our fake oauthToken call
    // once we have started the oauth flow (so we have the public keys!)
    const fxaClient = {};
    // Then, we initialize our oauth object with the given client and begin a new flow
    const oauth = new FxAccountsOAuth(fxaClient);
    const queryParams = await oauth.beginOAuthFlow(scopes);
    // Now that we have the public keys in `keys_jwk`, we use it to generate a JWE
    // representing our scoped keys
    const keysJwk = queryParams.keys_jwk;
    const decodedKeysJwk = JSON.parse(
      new TextDecoder().decode(
        ChromeUtils.base64URLDecode(keysJwk, { padding: "reject" })
      )
    );
    delete decodedKeysJwk.key_ops;
    const jwe = await jwcrypto.generateJWE(
      decodedKeysJwk,
      new TextEncoder().encode(JSON.stringify(plainTextScopedKeys))
    );
    // We also grab the stored PKCE verifier that the oauth object stored internally
    // to verify that we correctly send it as a part of our HTTP request
    const storedVerifier = oauth.getFlow(queryParams.state).verifier;

    // To test what happens when more than one flow is completed simulatniously
    // We mimic a slow network call on the first oauthToken call and let the second
    // one win
    let callCount = 0;
    let slowResolve;
    const resolveFn = (payload, resolve) => {
      if (callCount === 1) {
        // This is the second call
        // lets resolve it so the second call wins
        resolve(payload);
      } else {
        callCount += 1;
        // This is the first call, let store our resolve function for later
        // it will be resolved once the fast flow is fully completed
        slowResolve = () => resolve(payload);
      }
    };

    // Now we initialize our mock of the HTTP request, it verifies we passed in all the correct
    // parameters and returns what we'd expect a healthy HTTP Response would look like
    fxaClient.oauthToken = (sessionTokenHex, code, verifier, clientId) => {
      Assert.equal(sessionTokenHex, sessionToken);
      Assert.equal(code, oauthCode);
      Assert.equal(verifier, storedVerifier);
      Assert.equal(clientId, queryParams.client_id);
      const response = {
        access_token: fakeAccessToken,
        refresh_token: fakeRefreshToken,
        scope: scopes.join(" "),
        keys_jwe: jwe,
      };
      return new Promise(resolve => {
        resolveFn(response, resolve);
      });
    };

    // Then, we call the completeOAuthFlow function, and get back our access token,
    // refresh token and scopedKeys

    // To test what happens when multiple flows race, we create two flows,
    // A slow one that will start first, but finish last
    // And a fast one that will beat the slow one
    const firstCompleteOAuthFlow = oauth
      .completeOAuthFlow(sessionToken, oauthCode, queryParams.state)
      .then(res => {
        // To mimic the slow network connection on the slowCompleteOAuthFlow
        // We resume the slow completeOAuthFlow once this one is complete
        slowResolve();
        return res;
      });
    const secondCompleteOAuthFlow = oauth
      .completeOAuthFlow(sessionToken, oauthCode, queryParams.state)
      .then(res => {
        // since we can't fully gaurentee which oauth flow finishes first, we also resolve here
        slowResolve();
        return res;
      });

    const { accessToken, refreshToken, scopedKeys } = await Promise.allSettled([
      firstCompleteOAuthFlow,
      secondCompleteOAuthFlow,
    ]).then(results => {
      let fast;
      let slow;
      for (const result of results) {
        if (result.status === "fulfilled") {
          fast = result.value;
        } else {
          slow = result.reason;
        }
      }
      // We make sure that we indeed have one slow flow that lost
      Assert.equal(slow.message, ERROR_OAUTH_FLOW_ABANDONED);
      return fast;
    });

    Assert.equal(accessToken, fakeAccessToken);
    Assert.equal(refreshToken, fakeRefreshToken);
    Assert.deepEqual(scopedKeys, plainTextScopedKeys);

    // Finally, we verify that all stored flows were cleared
    Assert.equal(oauth.numOfFlows(), 0);
  });
});