Bug 1635657 - synthesize placeholder ecosystemAnonId when not present on FxA server r=markh,rfkelly

Differential Revision: https://phabricator.services.mozilla.com/D84617
This commit is contained in:
lougeniac64 2020-07-27 01:46:50 +00:00
Родитель 3ab28dbdec
Коммит 7456bd946c
6 изменённых файлов: 384 добавлений и 78 удалений

Просмотреть файл

@ -110,6 +110,7 @@ exports.COMMAND_SENDTAB = exports.COMMAND_PREFIX + exports.COMMAND_SENDTAB_TAIL;
// OAuth
exports.FX_OAUTH_CLIENT_ID = "5882386c6d801776";
exports.SCOPE_PROFILE = "profile";
exports.SCOPE_PROFILE_WRITE = "profile:write";
exports.SCOPE_OLD_SYNC = "https://identity.mozilla.com/apps/oldsync";
// OAuth metadata for other Firefox-related services that we might need to know about

Просмотреть файл

@ -212,7 +212,7 @@ var FxAccountsConfig = {
async ensureConfigured() {
let isSignedIn = !!(await this.getSignedInUser());
if (!isSignedIn) {
await this.fetchConfigURLs();
await this.updateConfigURLs();
}
},
@ -221,36 +221,14 @@ var FxAccountsConfig = {
// and replace all the relevant our prefs with the information found there.
// This is only done before sign-in and sign-up, and even then only if the
// `identity.fxaccounts.autoconfig.uri` preference is set.
async fetchConfigURLs() {
async updateConfigURLs() {
let rootURL = this.getAutoConfigURL();
if (!rootURL) {
return;
}
let configURL = rootURL + "/.well-known/fxa-client-configuration";
let request = new RESTRequest(configURL);
request.setHeader("Accept", "application/json");
// Catch and rethrow the error inline.
let resp = await request.get().catch(e => {
log.error(`Failed to get configuration object from "${configURL}"`, e);
throw e;
});
if (!resp.success) {
log.error(
`Received HTTP response code ${resp.status} from configuration object request`
);
if (resp.body) {
log.debug("Got error response", resp.body);
}
throw new Error(
`HTTP status ${resp.status} from configuration object request`
);
}
log.debug("Got successful configuration response", resp.body);
const config = await this.fetchConfigDocument(rootURL);
try {
// Update the prefs directly specified by the config.
let config = JSON.parse(resp.body);
let authServerBase = config.auth_server_base_url;
if (!authServerBase.endsWith("/v1")) {
authServerBase += "/v1";
@ -292,6 +270,44 @@ var FxAccountsConfig = {
}
},
// Read expected client configuration from the fxa auth server
// (or from the provided rootURL, if present) and return it as an object.
async fetchConfigDocument(rootURL = null) {
if (!rootURL) {
rootURL = ROOT_URL;
}
let configURL = rootURL + "/.well-known/fxa-client-configuration";
let request = new RESTRequest(configURL);
request.setHeader("Accept", "application/json");
// Catch and rethrow the error inline.
let resp = await request.get().catch(e => {
log.error(`Failed to get configuration object from "${configURL}"`, e);
throw e;
});
if (!resp.success) {
// Note: 'resp.body' is included with the error log below as we are not concerned
// that the body will contain PII, but if that changes it should be excluded.
log.error(
`Received HTTP response code ${resp.status} from configuration object request:
${resp.body}`
);
throw new Error(
`HTTP status ${resp.status} from configuration object request`
);
}
log.debug("Got successful configuration response", resp.body);
try {
return JSON.parse(resp.body);
} catch (e) {
log.error(
`Failed to parse configuration preferences from ${configURL}`,
e
);
throw e;
}
},
// For test purposes, returns a Promise.
getSignedInUser() {
return fxAccounts.getSignedInUser();

Просмотреть файл

@ -23,6 +23,7 @@ const {
ERROR_UNKNOWN,
log,
SCOPE_PROFILE,
SCOPE_PROFILE_WRITE,
} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
const { fxAccounts } = ChromeUtils.import(
"resource://gre/modules/FxAccounts.jsm"
@ -59,9 +60,6 @@ var FxAccountsProfileClient = function(options) {
} catch (e) {
throw new Error("Invalid 'serverURL'");
}
this.oauthOptions = {
scope: SCOPE_PROFILE,
};
log.debug("FxAccountsProfileClient: Initialized");
};
@ -83,19 +81,21 @@ FxAccountsProfileClient.prototype = {
* @param {String} path
* Profile server path, i.e "/profile".
* @param {String} [method]
* Type of request, i.e "GET".
* Type of request, e.g. "GET".
* @param {String} [etag]
* Optional ETag used for caching purposes.
* @param {Object} [body]
* Optional request body, to be sent as application/json.
* @return Promise
* Resolves: {body: Object, etag: Object} Successful response from the Profile server.
* Rejects: {FxAccountsProfileClientError} Profile client error.
* @private
*/
async _createRequest(path, method = "GET", etag = null) {
// tokens are cached, so getting them each request is cheap.
let token = await this.fxai.getOAuthToken(this.oauthOptions);
async _createRequest(path, method = "GET", etag = null, body = null) {
method = method.toUpperCase();
let token = await this._getTokenForRequest(method);
try {
return await this._rawRequest(path, method, token, etag);
return await this._rawRequest(path, method, token, etag, body);
} catch (ex) {
if (!(ex instanceof FxAccountsProfileClientError) || ex.code != 401) {
throw ex;
@ -105,11 +105,11 @@ FxAccountsProfileClient.prototype = {
"Fetching the profile returned a 401 - revoking our token and retrying"
);
await this.fxai.removeCachedOAuthToken({ token });
token = await this.fxai.getOAuthToken(this.oauthOptions);
token = await this._getTokenForRequest(method);
// and try with the new token - if that also fails then we fail after
// revoking the token.
try {
return await this._rawRequest(path, method, token, etag);
return await this._rawRequest(path, method, token, etag, body);
} catch (ex) {
if (!(ex instanceof FxAccountsProfileClientError) || ex.code != 401) {
throw ex;
@ -123,6 +123,26 @@ FxAccountsProfileClient.prototype = {
}
},
/**
* Helper to get an OAuth token for a request.
*
* OAuth tokens are cached, so it's fine to call this for each request.
*
* @param {String} [method]
* Type of request, i.e "GET".
* @return Promise
* Resolves: Object containing "scope", "token" and "key" properties
* Rejects: {FxAccountsProfileClientError} Profile client error.
* @private
*/
async _getTokenForRequest(method) {
let scope = SCOPE_PROFILE;
if (method === "POST") {
scope = SCOPE_PROFILE_WRITE;
}
return this.fxai.getOAuthToken({ scope });
},
/**
* Remote "raw" request helper - doesn't handle auth errors and tokens.
*
@ -132,16 +152,17 @@ FxAccountsProfileClient.prototype = {
* Type of request, i.e "GET".
* @param {String} token
* @param {String} etag
* @param {Object} payload
* The payload of the request, if any.
* @return Promise
* Resolves: {body: Object, etag: Object} Successful response from the Profile server
or null if 304 is hit (same ETag).
* Rejects: {FxAccountsProfileClientError} Profile client error.
* @private
*/
async _rawRequest(path, method, token, etag) {
async _rawRequest(path, method, token, etag = null, payload = null) {
let profileDataUrl = this.serverURL + path;
let request = new this._Request(profileDataUrl);
method = method.toUpperCase();
request.setHeader("Authorization", "Bearer " + token);
request.setHeader("Accept", "application/json");
@ -149,7 +170,7 @@ FxAccountsProfileClient.prototype = {
request.setHeader("If-None-Match", etag);
}
if (method != "GET") {
if (method != "GET" && method != "POST") {
// method not supported
throw new FxAccountsProfileClientError({
error: ERROR_NETWORK,
@ -158,9 +179,8 @@ FxAccountsProfileClient.prototype = {
message: ERROR_MSG_METHOD_NOT_ALLOWED,
});
}
try {
await request.get();
await request.dispatch(method, payload);
} catch (error) {
throw new FxAccountsProfileClientError({
error: ERROR_NETWORK,
@ -212,6 +232,27 @@ FxAccountsProfileClient.prototype = {
log.debug("FxAccountsProfileClient: Requested profile");
return this._createRequest("/profile", "GET", etag);
},
/**
* Write an ecosystemAnonId value to the user's profile data on the server.
*
* This should be used only if the user's profile data does not already contain an
* ecosytemAnonId field, and it will reject with a "412 Precondition Failed" if there
* is one already present on the server.
*
* @param {String} [ecosystemAnonId]
* The generated ecosystemAnonId value to store on the server.
* @return Promise
* Resolves: {body: Object} Successful response from the '/ecosystem_anon_id' endpoint.
* Rejects: {FxAccountsProfileClientError} profile client error.
*/
setEcosystemAnonId(ecosystemAnonId) {
log.debug("FxAccountsProfileClient: Setting ecosystemAnonId");
// This uses `If-None-Match: "*"` to prevent two concurrent clients from setting a value.
return this._createRequest("/ecosystem_anon_id", "POST", "*", {
ecosystemAnonId,
});
},
};
/**

Просмотреть файл

@ -18,7 +18,10 @@ XPCOMUtils.defineLazyModuleGetters(this, {
// "subject" data.
Observers: "resource://services-common/observers.js",
Services: "resource://gre/modules/Services.jsm",
CommonUtils: "resource://services-common/utils.js",
CryptoUtils: "resource://services-crypto/utils.js",
FxAccountsConfig: "resource://gre/modules/FxAccountsConfig.jsm",
jwcrypto: "resource://services-crypto/jwcrypto.jsm",
});
const { PREF_ACCOUNT_ROOT, log } = ChromeUtils.import(
@ -47,8 +50,7 @@ class FxAccountsTelemetry {
Observers.notify("fxa:telemetry:event", { object, method, value, extra });
}
// A flow ID can be anything that's "probably" unique, so for now use a UUID.
generateFlowID() {
generateUUID() {
return Cc["@mozilla.org/uuid-generator;1"]
.getService(Ci.nsIUUIDGenerator)
.generateUUID()
@ -56,6 +58,11 @@ class FxAccountsTelemetry {
.slice(1, -1);
}
// A flow ID can be anything that's "probably" unique, so for now use a UUID.
generateFlowID() {
return this.generateUUID();
}
// Account Ecosystem Telemetry identifies the user by a secret id called their "ecosystemUserId".
// To maintain user privacy this value must never be shared with Mozilla servers in plaintext
// (although there may be some client-side-only features that use it in future).
@ -95,14 +102,55 @@ class FxAccountsTelemetry {
// This asynchronous method resolves with the "ecosystemAnonId" value on success, and rejects
// with an error if no user is signed in or if the value could not be obtained from the
// FxA server.
async ensureEcosystemAnonId() {
const profile = await this._internal.profile.ensureProfile();
if (!profile.hasOwnProperty("ecosystemAnonId")) {
// In a future iteration, we can synthesize a placeholder ecosystemAnonId and persist it
// back to the FxA server.
throw new Error("Profile data does not contain an 'ecosystemAnonId'");
}
return profile.ecosystemAnonId;
async ensureEcosystemAnonId(generatePlaceholder = true) {
const telemetry = this;
return this._fxai.withCurrentAccountState(async function(state) {
const profile = await telemetry._fxai.profile.ensureProfile();
if (profile.hasOwnProperty("ecosystemAnonId")) {
return profile.ecosystemAnonId;
}
if (!generatePlaceholder) {
throw new Error("Profile data does not contain an 'ecosystemAnonId'");
}
// If the server doesn't have ecosystemAnonId yet, we can create a placeholder value.
// First, generate a random ecosystemUserId.
let ecosystemUserId = CommonUtils.bufferToHex(
CryptoUtils.generateRandomBytes(32)
);
// Now encrypt it to the AET public key, as advertized by the FxA server.
const serverConfig = await FxAccountsConfig.fetchConfigDocument();
const ecosystemKeys = serverConfig.ecosystem_anon_id_keys;
if (!ecosystemKeys || !ecosystemKeys.length) {
throw new Error(
"Unable to fetch ecosystem_anon_id_keys from FxA server"
);
}
const randomKey = Math.floor(
Math.random() * Math.floor(ecosystemKeys.length)
);
const ecosystemAnonId = await jwcrypto.generateJWE(
ecosystemKeys[randomKey],
new TextEncoder().encode(ecosystemUserId)
);
// Persist the encrypted value to the server so other clients can find it.
try {
await telemetry._fxai.profile.client.setEcosystemAnonId(
ecosystemAnonId
);
} catch (err) {
if (err && err.code && err.code === 412) {
// Another client raced us to upload the placeholder, fetch it.
return telemetry.ensureEcosystemAnonId(false);
}
throw err;
}
// Locally persist the unencrypted ecosystemUserId for possible future use.
ecosystemUserId = state.ecosystemUserId;
await state.updateUserAccountData({
ecosystemUserId,
});
return ecosystemAnonId;
});
}
// Prior to Account Ecosystem Telemetry, FxA- and Sync-related metrics were submitted in

Просмотреть файл

@ -37,7 +37,7 @@ let mockResponse = function(response) {
Request.ifNoneMatchSet = true;
}
},
async get() {
async dispatch(method, payload) {
this.response = response;
return this.response;
},
@ -72,7 +72,7 @@ let mockResponseError = function(error) {
return function() {
return {
setHeader() {},
async get() {
async dispatch(method, payload) {
throw error;
},
};
@ -232,7 +232,7 @@ add_test(function server401ResponseThenSuccess() {
Assert.equal(value, "Bearer " + lastToken);
}
},
async get() {
async dispatch(method, payload) {
this.response = responses[numRequests];
++numRequests;
return this.response;
@ -295,7 +295,7 @@ add_test(function server401ResponsePersists() {
Assert.equal(value, "Bearer " + lastToken);
}
},
async get() {
async dispatch(method, payload) {
this.response = response;
++numRequests;
return this.response;

Просмотреть файл

@ -15,10 +15,19 @@ const { FxAccountsProfile } = ChromeUtils.import(
"resource://gre/modules/FxAccountsProfile.jsm"
);
const { FxAccountsProfileClient } = ChromeUtils.import(
"resource://gre/modules/FxAccountsProfileClient.jsm"
);
const { FxAccountsTelemetry } = ChromeUtils.import(
"resource://gre/modules/FxAccountsTelemetry.jsm"
);
XPCOMUtils.defineLazyModuleGetters(this, {
FxAccountsConfig: "resource://gre/modules/FxAccountsConfig.jsm",
jwcrypto: "resource://services-crypto/jwcrypto.jsm",
});
_("Misc tests for FxAccounts.telemetry");
const MOCK_HASHED_UID = "00112233445566778899aabbccddeeff";
@ -63,8 +72,8 @@ add_task(function test_sanitize_device_id() {
});
add_task(async function test_getEcosystemAnonId() {
let ecosystemAnonId = "aaaaaaaaaaaaaaa";
let testCases = [
const ecosystemAnonId = "aaaaaaaaaaaaaaa";
const testCases = [
{
// testing retrieving the ecosystemAnonId when the profile contains it
throw: false,
@ -86,8 +95,10 @@ add_task(async function test_getEcosystemAnonId() {
];
for (const tc of testCases) {
let profile = new FxAccountsProfile({ profileServerUrl: "http://testURL" });
let telemetry = new FxAccountsTelemetry({});
const profile = new FxAccountsProfile({
profileServerUrl: "http://testURL",
});
const telemetry = new FxAccountsTelemetry({});
telemetry._internal = { profile };
const mockProfile = sinon.mock(profile);
const mockTelemetry = sinon.mock(telemetry);
@ -113,49 +124,238 @@ add_task(async function test_getEcosystemAnonId() {
.resolves("dddddddddd");
}
let actualEcoSystemAnonId = await telemetry.getEcosystemAnonId();
const actualEcoSystemAnonId = await telemetry.getEcosystemAnonId();
mockProfile.verify();
mockTelemetry.verify();
Assert.equal(actualEcoSystemAnonId, tc.expectedEcosystemAnonId);
}
});
add_task(async function test_ensureEcosystemAnonId() {
let ecosystemAnonId = "bbbbbbbbbbbbbb";
let testCases = [
add_task(async function test_ensureEcosystemAnonId_fromProfile() {
const expecteErrorMessage =
"Profile data does not contain an 'ecosystemAnonId'";
const expectedEcosystemAnonId = "aaaaaaaaaaa";
const testCases = [
{
profile: {
ecosystemAnonId,
ecosystemAnonId: expectedEcosystemAnonId,
},
willErr: false,
generatePlaceholder: true,
},
{
profile: {},
willErr: true,
generatePlaceholder: false,
},
];
for (const tc of testCases) {
let profile = new FxAccountsProfile({ profileServerUrl: "http://testURL" });
let telemetry = new FxAccountsTelemetry({});
telemetry._internal = { profile };
let mockProfile = sinon.mock(profile);
const profile = new FxAccountsProfile({
profileServerUrl: "http://testURL",
});
const telemetry = new FxAccountsTelemetry({
profile,
withCurrentAccountState: async cb => {
return cb({});
},
});
const mockProfile = sinon.mock(profile);
mockProfile
.expects("ensureProfile")
.once()
.returns(tc.profile);
if (tc.willErr) {
const expectedError = `Profile data does not contain an 'ecosystemAnonId'`;
try {
await telemetry.ensureEcosystemAnonId();
} catch (e) {
Assert.equal(e.message, expectedError);
}
if (tc.profile.ecosystemAnonId) {
const actualEcoSystemAnonId = await telemetry.ensureEcosystemAnonId();
Assert.equal(actualEcoSystemAnonId, expectedEcosystemAnonId);
} else {
let actualEcoSystemAnonId = await telemetry.ensureEcosystemAnonId();
Assert.equal(actualEcoSystemAnonId, ecosystemAnonId);
try {
await telemetry.ensureEcosystemAnonId(tc.generatePlaceholder);
} catch (e) {
Assert.equal(expecteErrorMessage, e.message);
}
}
mockProfile.verify();
}
});
add_task(async function test_ensureEcosystemAnonId_failToGenerateKeys() {
const expectedErrorMessage =
"Unable to fetch ecosystem_anon_id_keys from FxA server";
const testCases = [
{
serverConfig: {},
},
{
serverConfig: {
ecosystem_anon_id_keys: [],
},
},
];
for (const tc of testCases) {
const profile = new FxAccountsProfile({
profileServerUrl: "http://testURL",
});
const telemetry = new FxAccountsTelemetry({
profile,
withCurrentAccountState: async cb => {
return cb({});
},
});
const mockProfile = sinon.mock(profile);
const mockFxAccountsConfig = sinon.mock(FxAccountsConfig);
mockProfile
.expects("ensureProfile")
.once()
.returns({});
mockFxAccountsConfig
.expects("fetchConfigDocument")
.once()
.returns(tc.serverConfig);
try {
await telemetry.ensureEcosystemAnonId(true);
} catch (e) {
Assert.equal(expectedErrorMessage, e.message);
mockProfile.verify();
mockFxAccountsConfig.verify();
}
}
});
add_task(async function test_ensureEcosystemAnonId_clientRace() {
const expectedEcosystemAnonId = "bbbbbbbbbbbb";
const expectedErrrorMessage = "test error at 'setEcosystemAnonId'";
const testCases = [
{
errorCode: 412,
errorMessage: null,
},
{
errorCode: 405,
errorMessage: expectedErrrorMessage,
},
];
for (const tc of testCases) {
const profileClient = new FxAccountsProfileClient({
serverURL: "http://testURL",
});
const profile = new FxAccountsProfile({ profileClient });
const telemetry = new FxAccountsTelemetry({
profile,
withCurrentAccountState: async cb => {
return cb({});
},
});
const mockProfile = sinon.mock(profile);
const mockFxAccountsConfig = sinon.mock(FxAccountsConfig);
const mockJwcrypto = sinon.mock(jwcrypto);
const mockProfileClient = sinon.mock(profileClient);
mockProfile
.expects("ensureProfile")
.once()
.returns({});
mockFxAccountsConfig
.expects("fetchConfigDocument")
.once()
.returns({
ecosystem_anon_id_keys: ["testKey"],
});
mockJwcrypto
.expects("generateJWE")
.once()
.returns(expectedEcosystemAnonId);
mockProfileClient
.expects("setEcosystemAnonId")
.once()
.throws({
code: tc.errorCode,
message: tc.errorMessage,
});
if (tc.errorCode === 412) {
mockProfile
.expects("ensureProfile")
.once()
.returns({
ecosystemAnonId: expectedEcosystemAnonId,
});
const actualEcosystemAnonId = await telemetry.ensureEcosystemAnonId(true);
Assert.equal(expectedEcosystemAnonId, actualEcosystemAnonId);
} else {
try {
await telemetry.ensureEcosystemAnonId(true);
} catch (e) {
Assert.equal(expectedErrrorMessage, e.message);
}
}
mockProfile.verify();
mockFxAccountsConfig.verify();
mockJwcrypto.verify();
mockProfileClient.verify();
}
});
add_task(async function test_ensureEcosystemAnonId_updateAccountData() {
const expectedEcosystemUserId = "aaaaaaaaaa";
const expectedEcosystemAnonId = "bbbbbbbbbbbb";
const profileClient = new FxAccountsProfileClient({
serverURL: "http://testURL",
});
const profile = new FxAccountsProfile({ profileClient });
const fxa = new FxAccounts();
fxa.withCurrentAccountState = async cb =>
cb({
ecosystemUserId: expectedEcosystemUserId,
updateUserAccountData: fields => {
Assert.equal(expectedEcosystemUserId, fields.ecosystemUserId);
},
});
fxa.profile = profile;
const telemetry = new FxAccountsTelemetry(fxa);
const mockProfile = sinon.mock(profile);
const mockFxAccountsConfig = sinon.mock(FxAccountsConfig);
const mockJwcrypto = sinon.mock(jwcrypto);
const mockProfileClient = sinon.mock(profileClient);
mockProfile
.expects("ensureProfile")
.once()
.returns({});
mockFxAccountsConfig
.expects("fetchConfigDocument")
.once()
.returns({
ecosystem_anon_id_keys: ["testKey"],
});
mockJwcrypto
.expects("generateJWE")
.once()
.returns(expectedEcosystemAnonId);
mockProfileClient
.expects("setEcosystemAnonId")
.once()
.returns(null);
const actualEcosystemAnonId = await telemetry.ensureEcosystemAnonId(true);
Assert.equal(expectedEcosystemAnonId, actualEcosystemAnonId);
mockProfile.verify();
mockFxAccountsConfig.verify();
mockJwcrypto.verify();
mockProfileClient.verify();
});