Bug 1869641: Adds oauth handling for fxa behind pref. r=skhamis

- Adds new pref disabled by default that enables the oauth flow
- When the pref is enabled, the URLs returned by FxA are oauth urls
- Adds listening to oauth login web channel message from FxA

Differential Revision: https://phabricator.services.mozilla.com/D196314
This commit is contained in:
Tarik Eshaq 2024-01-17 16:38:02 +00:00
Родитель 9210241675
Коммит 8e1906638e
12 изменённых файлов: 261 добавлений и 50 удалений

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

@ -1850,6 +1850,9 @@ pref("identity.fxaccounts.remote.root", "https://accounts.firefox.com/");
// The value of the context query parameter passed in fxa requests.
pref("identity.fxaccounts.contextParam", "fx_desktop_v3");
// Whether to use the oauth flow for desktop or not
pref("identity.fxaccounts.oauth.enabled", false);
// The remote URL of the FxA Profile Server
pref("identity.fxaccounts.remote.profile.uri", "https://profile.accounts.firefox.com/v1");

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

@ -831,7 +831,7 @@ FxAccountsInternal.prototype = {
_oauth: null,
get oauth() {
if (!this._oauth) {
this._oauth = new lazy.FxAccountsOAuth();
this._oauth = new lazy.FxAccountsOAuth(this.fxAccountsClient);
}
return this._oauth;
},
@ -844,6 +844,18 @@ FxAccountsInternal.prototype = {
return this._telemetry;
},
beginOAuthFlow(scopes) {
return this.oauth.beginOAuthFlow(scopes);
},
completeOAuthFlow(sessionToken, code, state) {
return this.oauth.completeOAuthFlow(sessionToken, code, state);
},
setScopedKeys(scopedKeys) {
return this.keys.setScopedKeys(scopedKeys);
},
// A hook-point for tests who may want a mocked AccountState or mocked storage.
newAccountState(credentials) {
let storage = new FxAccountsStorageManager();
@ -1031,7 +1043,10 @@ FxAccountsInternal.prototype = {
return this.startPollEmailStatus(state, data.sessionToken, "push");
},
_destroyOAuthToken(tokenData) {
/** Destroyes an OAuth Token by sending a request to the FxA server
* @param { Object } tokenData: The token's data, with `tokenData.token` being the token itself
**/
destroyOAuthToken(tokenData) {
return this.fxAccountsClient.oauthDestroy(
FX_OAUTH_CLIENT_ID,
tokenData.token
@ -1045,7 +1060,7 @@ FxAccountsInternal.prototype = {
// let's just destroy them all in parallel...
let promises = [];
for (let tokenInfo of Object.values(tokenInfos)) {
promises.push(this._destroyOAuthToken(tokenInfo));
promises.push(this.destroyOAuthToken(tokenInfo));
}
return Promise.all(promises);
},
@ -1419,13 +1434,25 @@ FxAccountsInternal.prototype = {
let existing = currentState.removeCachedToken(options.token);
if (existing) {
// background destroy.
this._destroyOAuthToken(existing).catch(err => {
this.destroyOAuthToken(existing).catch(err => {
log.warn("FxA failed to revoke a cached token", err);
});
}
});
},
/** Sets the user to be verified in the account state,
* This prevents any polling for the user's verification state from the FxA server
**/
setUserVerified() {
return this.withCurrentAccountState(async currentState => {
const userData = await currentState.getUserAccountData();
if (!userData.verified) {
await currentState.updateAccountData({ verified: true });
}
});
},
async _getVerifiedAccountOrReject() {
let data = await this.currentAccountState.getUserAccountData();
if (!data) {

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

@ -284,21 +284,27 @@ FxAccountsClient.prototype = {
},
/**
* Exchanges an OAuth authorization code with a refresh token, access tokens and an optional JWE representing scoped keys
* Takes in the sessionToken to tie the device record associated with the session, with the device record associated with the refreshToken
*
* @param string sessionTokenHex: The session token encoded in hex
* @param String code: OAuth authorization code
* @param String verifier: OAuth PKCE verifier
* @param String clientId: OAuth client ID
*
* @returns { Object } object containing `refresh_token`, `access_token` and `keys_jwe`
**/
async oauthToken(code, verifier, clientId) {
async oauthToken(sessionTokenHex, code, verifier, clientId) {
const credentials = await deriveHawkCredentials(
sessionTokenHex,
"sessionToken"
);
const body = {
grant_type: "authorization_code",
code,
client_id: clientId,
code_verifier: verifier,
};
return this._request("/oauth/token", "POST", null, body);
return this._request("/oauth/token", "POST", credentials, body);
},
/**
* Destroy an OAuth access token or refresh token.

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

@ -115,6 +115,7 @@ export let COMMAND_PAIR_COMPLETE = "fxaccounts:pair_complete";
export let COMMAND_PROFILE_CHANGE = "profile:change";
export let COMMAND_CAN_LINK_ACCOUNT = "fxaccounts:can_link_account";
export let COMMAND_LOGIN = "fxaccounts:login";
export let COMMAND_OAUTH = "fxaccounts:oauth_login";
export let COMMAND_LOGOUT = "fxaccounts:logout";
export let COMMAND_DELETE = "fxaccounts:delete";
export let COMMAND_SYNC_PREFERENCES = "fxaccounts:sync_preferences";

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

@ -4,7 +4,11 @@
import { RESTRequest } from "resource://services-common/rest.sys.mjs";
import { log } from "resource://gre/modules/FxAccountsCommon.sys.mjs";
import {
log,
SCOPE_OLD_SYNC,
SCOPE_PROFILE,
} from "resource://gre/modules/FxAccountsCommon.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
@ -51,25 +55,33 @@ const SYNC_PARAM = "sync";
export var FxAccountsConfig = {
async promiseEmailURI(email, entrypoint, extraParams = {}) {
const authParams = await this._getAuthParam();
return this._buildURL("", {
extraParams: { entrypoint, email, service: SYNC_PARAM, ...extraParams },
extraParams: {
entrypoint,
email,
...authParams,
...extraParams,
},
});
},
async promiseConnectAccountURI(entrypoint, extraParams = {}) {
const authParams = await this._getAuthParams();
return this._buildURL("", {
extraParams: {
entrypoint,
action: "email",
service: SYNC_PARAM,
...authParams,
...extraParams,
},
});
},
async promiseForceSigninURI(entrypoint, extraParams = {}) {
const authParams = await this._getAuthParams();
return this._buildURL("force_auth", {
extraParams: { entrypoint, service: SYNC_PARAM, ...extraParams },
extraParams: { entrypoint, ...authParams, ...extraParams },
addAccountIdentifiers: true,
});
},
@ -330,4 +342,19 @@ export var FxAccountsConfig = {
getSignedInUser() {
return lazy.fxAccounts.getSignedInUser();
},
_isOAuthFlow() {
return Services.prefs.getBoolPref(
"identity.fxaccounts.oauth.enabled",
false
);
},
async _getAuthParams() {
if (this._isOAuthFlow()) {
const scopes = [SCOPE_OLD_SYNC, SCOPE_PROFILE];
return lazy.fxAccounts._internal.beginOAuthFlow(scopes);
}
return { service: SYNC_PARAM };
},
};

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

@ -215,6 +215,25 @@ export class FxAccountsKeys {
});
}
/**
* Set externally derived scoped keys in internal storage
* @param { Object } scopedKeys: The scoped keys object derived by the oauth flow
*
* @return { Promise }: A promise that resolves if the keys were successfully stored,
* or rejects if we failed to persist the keys, or if the user is not signed in already
*/
async setScopedKeys(scopedKeys) {
return this._fxai.withCurrentAccountState(async currentState => {
const userData = await currentState.getUserAccountData();
if (!userData) {
throw new Error("Cannot persist keys, no user signed in");
}
await currentState.updateUserAccountData({
scopedKeys,
});
});
}
/**
* Key storage migration or fetching logic.
*

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

@ -168,6 +168,7 @@ export class FxAccountsOAuth {
}
/** Completes an OAuth flow and invalidates any other ongoing flows
* @param { string } sessionTokenHex: The session token encoded in hexadecimal
* @param { string } code: OAuth authorization code provided by running an OAuth flow
* @param { string } state: The state first provided by `beginOAuthFlow`, then roundtripped through the server
*
@ -177,14 +178,19 @@ export class FxAccountsOAuth {
* - 'refreshToken': The refresh token provided by the server
* - 'accessToken': The access token provided by the server
* */
async completeOAuthFlow(code, state) {
async completeOAuthFlow(sessionTokenHex, code, state) {
const flow = this.getFlow(state);
if (!flow) {
throw new Error(ERROR_INVALID_STATE);
}
const { key, verifier, requestedScopes } = flow;
const { keys_jwe, refresh_token, access_token, scope } =
await this.#fxaClient.oauthToken(code, verifier, FX_OAUTH_CLIENT_ID);
await this.#fxaClient.oauthToken(
sessionTokenHex,
code,
verifier,
FX_OAUTH_CLIENT_ID
);
if (
requestedScopes.includes(SCOPE_OLD_SYNC) &&
!scope.includes(SCOPE_OLD_SYNC)

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

@ -15,6 +15,7 @@ import {
COMMAND_PROFILE_CHANGE,
COMMAND_LOGIN,
COMMAND_LOGOUT,
COMMAND_OAUTH,
COMMAND_DELETE,
COMMAND_CAN_LINK_ACCOUNT,
COMMAND_SYNC_PREFERENCES,
@ -84,6 +85,19 @@ XPCOMUtils.defineLazyPreferenceGetter(
// older versions of Firefox.
const EXTRA_ENGINES = ["addresses", "creditcards"];
// These engines will be displayed to the user to pick which they would like to
// use
const CHOOSE_WHAT_TO_SYNC = [
"addons",
"addresses",
"bookmarks",
"creditcards",
"history",
"passwords",
"preferences",
"tabs",
];
/**
* A helper function that extracts the message and stack from an error object.
* Returns a `{ message, stack }` tuple. `stack` will be null if the error
@ -220,6 +234,11 @@ FxAccountsWebChannel.prototype = {
.login(data)
.catch(error => this._sendError(error, message, sendingContext));
break;
case COMMAND_OAUTH:
this._helpers
.oauthLogin(data)
.catch(error => this._sendError(error, message, sendingContext));
break;
case COMMAND_LOGOUT:
case COMMAND_DELETE:
this._helpers
@ -408,6 +427,37 @@ FxAccountsWebChannelHelpers.prototype = {
);
},
async _initializeSync() {
// A sync-specific hack - we want to ensure sync has been initialized
// before we set the signed-in user.
// XXX - probably not true any more, especially now we have observerPreloads
// in FxAccounts.jsm?
let xps =
this._weaveXPCOM ||
Cc["@mozilla.org/weave/service;1"].getService(Ci.nsISupports)
.wrappedJSObject;
await xps.whenLoaded();
return xps;
},
_setEnabledEngines(offeredEngines, declinedEngines) {
if (offeredEngines && declinedEngines) {
EXTRA_ENGINES.forEach(engine => {
if (
offeredEngines.includes(engine) &&
!declinedEngines.includes(engine)
) {
// These extra engines are disabled by default.
Services.prefs.setBoolPref(`services.sync.engine.${engine}`, true);
}
});
log.debug("Received declined engines", declinedEngines);
lazy.Weave.Service.engineManager.setDeclined(declinedEngines);
declinedEngines.forEach(engine => {
Services.prefs.setBoolPref(`services.sync.engine.${engine}`, false);
});
}
},
/**
* stores sync login info it in the fxaccounts service
*
@ -437,46 +487,50 @@ FxAccountsWebChannelHelpers.prototype = {
"webchannel"
);
// A sync-specific hack - we want to ensure sync has been initialized
// before we set the signed-in user.
// XXX - probably not true any more, especially now we have observerPreloads
// in FxAccounts.jsm?
let xps =
this._weaveXPCOM ||
Cc["@mozilla.org/weave/service;1"].getService(Ci.nsISupports)
.wrappedJSObject;
await xps.whenLoaded();
const xps = await this._initializeSync();
await this._fxAccounts._internal.setSignedInUser(accountData);
if (requestedServices) {
// User has enabled Sync.
if (requestedServices.sync) {
const { offeredEngines, declinedEngines } = requestedServices.sync;
if (offeredEngines && declinedEngines) {
EXTRA_ENGINES.forEach(engine => {
if (
offeredEngines.includes(engine) &&
!declinedEngines.includes(engine)
) {
// These extra engines are disabled by default.
Services.prefs.setBoolPref(
`services.sync.engine.${engine}`,
true
);
}
});
log.debug("Received declined engines", declinedEngines);
lazy.Weave.Service.engineManager.setDeclined(declinedEngines);
declinedEngines.forEach(engine => {
Services.prefs.setBoolPref(`services.sync.engine.${engine}`, false);
});
}
this._setEnabledEngines(offeredEngines, declinedEngines);
log.debug("Webchannel is enabling sync");
await xps.Weave.Service.configure();
}
}
},
/**
* Logins in to sync by completing an OAuth flow
* @param { Object } oauthData: The oauth code and state as returned by the server */
async oauthLogin(oauthData) {
log.debug("Webchannel is completing the oauth flow");
const xps = await this._initializeSync();
const { code, state, declinedSyncEngines, offeredSyncEngines } = oauthData;
const { sessionToken } =
await this._fxAccounts._internal.getUserAccountData(["sessionToken"]);
// First we finish the ongoing oauth flow
const { scopedKeys, refreshToken } =
await this._fxAccounts._internal.completeOAuthFlow(
sessionToken,
code,
state
);
// We don't currently use the refresh token in Firefox Desktop, lets be good citizens and revoke it.
await this._fxAccounts._internal.destroyOAuthToken({ token: refreshToken });
// Then, we persist the sync keys
await this._fxAccounts._internal.setScopedKeys(scopedKeys);
// Now that we have the scoped keys, we set our status to verified
await this._fxAccounts._internal.setUserVerified();
this._setEnabledEngines(offeredSyncEngines, declinedSyncEngines);
log.debug("Webchannel is enabling sync");
xps.Weave.Service.configure();
},
/**
* logout the fxaccounts service
*
@ -565,14 +619,29 @@ FxAccountsWebChannelHelpers.prototype = {
}
}
const capabilities = this._getCapabilities();
return {
signedInUser,
clientId: FX_OAUTH_CLIENT_ID,
capabilities: {
capabilities,
};
},
_getCapabilities() {
if (
Services.prefs.getBoolPref("identity.fxaccounts.oauth.enabled", false)
) {
return {
multiService: true,
pairing: lazy.pairingEnabled,
choose_what_to_sync: true,
engines: CHOOSE_WHAT_TO_SYNC,
};
}
return {
multiService: true,
pairing: lazy.pairingEnabled,
engines: this._getAvailableExtraEngines(),
},
};
},

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

@ -940,6 +940,29 @@ add_task(async function test_getScopedKeys_misconfigured_fxa_server() {
);
});
add_task(async function test_setScopedKeys() {
const fxa = new MockFxAccounts();
const user = {
...getTestUser("foo"),
verified: true,
};
await fxa.setSignedInUser(user);
await fxa.keys.setScopedKeys(MOCK_ACCOUNT_KEYS.scopedKeys);
const key = await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC);
Assert.deepEqual(key, {
scope: SCOPE_OLD_SYNC,
...MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC],
});
});
add_task(async function test_setScopedKeys_user_not_signed_in() {
const fxa = new MockFxAccounts();
await Assert.rejects(
fxa.keys.setScopedKeys(MOCK_ACCOUNT_KEYS.scopedKeys),
/Cannot persist keys, no user signed in/
);
});
// _fetchAndUnwrapAndDeriveKeys with no keyFetchToken should trigger signOut
// XXX - actually, it probably shouldn't - bug 1572313.
add_test(function test_fetchAndUnwrapAndDeriveKeys_no_token() {

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

@ -99,8 +99,9 @@ add_task(function test_complete_oauth_flow() {
const oauth = new FxAccountsOAuth();
const code = "foo";
const state = "bar";
const sessionToken = "01abcef12";
try {
await oauth.completeOAuthFlow(code, state);
await oauth.completeOAuthFlow(sessionToken, code, state);
Assert.fail("Should have thrown an error");
} catch (err) {
Assert.equal(err.message, ERROR_INVALID_STATE);
@ -118,9 +119,10 @@ add_task(function test_complete_oauth_flow() {
};
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("foo", queryParams.state);
await oauth.completeOAuthFlow(sessionToken, "foo", queryParams.state);
Assert.fail(
"Should have thrown an error because the sync scope was not authorized"
);
@ -140,8 +142,9 @@ add_task(function test_complete_oauth_flow() {
};
const oauth = new FxAccountsOAuth(fxaClient);
const queryParams = await oauth.beginOAuthFlow(scopes);
const sessionToken = "01abcef12";
try {
await oauth.completeOAuthFlow("foo", queryParams.state);
await oauth.completeOAuthFlow(sessionToken, "foo", queryParams.state);
Assert.fail(
"Should have thrown an error because we didn't get back a keys_nwe"
);
@ -154,6 +157,7 @@ add_task(function test_complete_oauth_flow() {
// 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",
@ -204,7 +208,8 @@ add_task(function test_complete_oauth_flow() {
// 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 = (code, verifier, clientId) => {
fxaClient.oauthToken = (sessionTokenHex, code, verifier, clientId) => {
Assert.equal(sessionTokenHex, sessionToken);
Assert.equal(code, oauthCode);
Assert.equal(verifier, storedVerifier);
Assert.equal(clientId, queryParams.client_id);
@ -226,7 +231,7 @@ add_task(function test_complete_oauth_flow() {
// A slow one that will start first, but finish last
// And a fast one that will beat the slow one
const firstCompleteOAuthFlow = oauth
.completeOAuthFlow(oauthCode, queryParams.state)
.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
@ -234,7 +239,7 @@ add_task(function test_complete_oauth_flow() {
return res;
});
const secondCompleteOAuthFlow = oauth
.completeOAuthFlow(oauthCode, queryParams.state)
.completeOAuthFlow(sessionToken, oauthCode, queryParams.state)
.then(res => {
// since we can't fully gaurentee which oauth flow finishes first, we also resolve here
slowResolve();

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

@ -257,6 +257,31 @@ add_test(function test_login_message() {
channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
});
add_test(function test_oauth_login() {
const mockData = {
code: "oauth code",
state: "state parameter",
declinedSyncEngines: ["tabs", "creditcards"],
offeredSyncEngines: ["tabs", "creditcards", "history"],
};
const mockMessage = {
command: "fxaccounts:oauth_login",
data: mockData,
};
const channel = new FxAccountsWebChannel({
channel_id: WEBCHANNEL_ID,
content_uri: URL_STRING,
helpers: {
oauthLogin(data) {
Assert.deepEqual(data, mockData);
run_next_test();
return Promise.resolve();
},
},
});
channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
});
add_test(function test_logout_message() {
let mockMessage = {
command: "fxaccounts:logout",

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

@ -170,7 +170,7 @@ export var makeFxAccountsInternalMock = function (config) {
return accountState;
},
getOAuthToken: () => Promise.resolve("some-access-token"),
_destroyOAuthToken: () => Promise.resolve(),
destroyOAuthToken: () => Promise.resolve(),
keys: {
getScopedKeys: () =>
Promise.resolve({