Source code
Revision control
Copy as Markdown
Other Tools
Test Info: Warnings
- This test gets skipped with pattern: os == 'android' OR appname == 'thunderbird' && !nightly_build
- Manifest: services/fxaccounts/tests/xpcshell/xpcshell.toml
/* Any copyright is dedicated to the Public Domain.
/* global crypto */
"use strict";
const {
FxAccountsOAuth,
ERROR_INVALID_SCOPES,
ERROR_INVALID_SCOPED_KEYS,
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, OAUTH_CLIENT_ID } = ChromeUtils.importESModule(
"resource://gre/modules/FxAccountsCommon.sys.mjs"
);
ChromeUtils.defineESModuleGetters(this, {
FxAccountsKeys: "resource://gre/modules/FxAccountsKeys.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_APP_SYNC];
const queryParams = await oauth.beginOAuthFlow(scopes);
// First verify default query parameters
Assert.equal(queryParams.client_id, 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_APP_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_APP_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_APP_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_APP_SYNC];
const oauthCode = "fake oauth code";
const sessionToken = "01abcef12";
const plainTextScopedKeys = {
[SCOPE_APP_SYNC]: {
kty: "oct",
kid: "1510726318123-IqQv4onc7VcVE1kTQkyyOw",
k: "DW_ll5GwX6SJ5GPqJVAuMUP2t6kDqhUulc2cbt26xbTcaKGQl-9l29FHAQ7kUiJETma4s9fIpEHrt909zgFang",
scope: SCOPE_APP_SYNC,
},
};
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 = {};
const fxaKeys = new FxAccountsKeys(null);
// Then, we initialize our oauth object with the given client and begin a new flow
const oauth = new FxAccountsOAuth(fxaClient, fxaKeys);
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);
});
add_task(async function test_complete_oauth_invalid_scoped_keys() {
// First, we initialize some fake values we would typically get
// from outside our system
const scopes = [SCOPE_PROFILE, SCOPE_APP_SYNC];
const oauthCode = "fake oauth code";
const sessionToken = "01abcef12";
const invalidScopedKeys = {
[SCOPE_APP_SYNC]: {
// ====== This is an invalid key type! Should be "oct", so we will raise an error once we realize
kty: "EC",
kid: "1510726318123-IqQv4onc7VcVE1kTQkyyOw",
k: "DW_ll5GwX6SJ5GPqJVAuMUP2t6kDqhUulc2cbt26xbTcaKGQl-9l29FHAQ7kUiJETma4s9fIpEHrt909zgFang",
scope: SCOPE_APP_SYNC,
},
};
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 = {};
const fxaKeys = new FxAccountsKeys(null);
// Then, we initialize our oauth object with the given client and begin a new flow
const oauth = new FxAccountsOAuth(fxaClient, fxaKeys);
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(invalidScopedKeys))
);
// 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;
// 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 Promise.resolve(response);
};
// Then, we call the completeOAuthFlow function, and get back our access token,
// refresh token and scopedKeys
try {
await oauth.completeOAuthFlow(sessionToken, oauthCode, queryParams.state);
Assert.fail(
"Should have thrown an error because the scoped keys are not valid"
);
} catch (err) {
Assert.equal(err.message, ERROR_INVALID_SCOPED_KEYS);
}
});
});