348 lines
13 KiB
JavaScript
348 lines
13 KiB
JavaScript
/* 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_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, {
|
|
jwcrypto: "resource://services-crypto/jwcrypto.sys.mjs",
|
|
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);
|
|
}
|
|
});
|
|
});
|