diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /services/fxaccounts/tests/xpcshell/test_oauth_flow.js | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'services/fxaccounts/tests/xpcshell/test_oauth_flow.js')
-rw-r--r-- | services/fxaccounts/tests/xpcshell/test_oauth_flow.js | 274 |
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); + }); +}); |