summaryrefslogtreecommitdiffstats
path: root/services/fxaccounts/tests/xpcshell/test_oauth_flow.js
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--services/fxaccounts/tests/xpcshell/test_oauth_flow.js274
1 files changed, 274 insertions, 0 deletions
diff --git a/services/fxaccounts/tests/xpcshell/test_oauth_flow.js b/services/fxaccounts/tests/xpcshell/test_oauth_flow.js
new file mode 100644
index 0000000000..ef5102ae17
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_oauth_flow.js
@@ -0,0 +1,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);
+ });
+});