Bug 1631830 - Fetch Sync tokens with OAuth behind a pref r=rfkelly

Differential Revision: https://phabricator.services.mozilla.com/D72092
This commit is contained in:
Vlad Filippov 2020-04-28 04:20:55 +00:00
parent af3bb17589
commit cf50ebbe18
9 changed files with 188 additions and 52 deletions

View File

@ -1422,6 +1422,9 @@ pref("identity.fxaccounts.remote.pairing.uri", "wss://channelserver.services.moz
// Token server used by the FxA Sync identity.
pref("identity.sync.tokenserver.uri", "https://token.services.mozilla.com/1.0/sync/1.5");
// Fetch Sync tokens using the OAuth token function
pref("identity.sync.useOAuthForSyncToken", false);
// Auto-config URL for FxA self-hosters, makes an HTTP request to
// [identity.fxaccounts.autoconfig.uri]/.well-known/fxa-client-configuration
// This is now the prefered way of pointing to a custom FxA server, instead

View File

@ -157,16 +157,19 @@ TokenServerClient.prototype = {
/**
* Obtain a token from a BrowserID assertion against a specific URL.
*
* This asynchronously obtains the token. The callback receives 2 arguments:
* This asynchronously obtains the token.
* It returns a Promise that resolves or rejects:
*
* (TokenServerClientError | null) If no token could be obtained, this
* Rejects with:
* (TokenServerClientError) If no token could be obtained, this
* will be a TokenServerClientError instance describing why. The
* type seen defines the type of error encountered. If an HTTP response
* was seen, a RESTResponse instance will be stored in the `response`
* property of this object. If there was no error and a token is
* available, this will be null.
*
* (map | null) On success, this will be a map containing the results from
* Resolves with:
* (map) On success, this will be a map containing the results from
* the server. If there was an error, this will be null. The map has the
* following properties:
*
@ -218,23 +221,72 @@ TokenServerClient.prototype = {
* (string) URL to fetch token from.
* @param assertion
* (string) BrowserID assertion to exchange token for.
* @param conditionsAccepted
* (bool) Whether to send acceptance to service conditions.
* @param addHeaders
* (object) Extra headers for the request.
*/
async getTokenFromBrowserIDAssertion(url, assertion, addHeaders = {}) {
if (!url) {
throw new TokenServerClientError("url argument is not valid.");
}
this._log.debug("Beginning BID assertion exchange: " + url);
if (!assertion) {
throw new TokenServerClientError("assertion argument is not valid.");
}
this._log.debug("Beginning BID assertion exchange: " + url);
return this._tokenServerExchangeRequest(
url,
`BrowserID ${assertion}`,
addHeaders
);
},
/**
* Obtain a token from a provided OAuth token against a specific URL.
*
* @param url
* (string) URL to fetch token from.
* @param oauthToken
* (string) FxA OAuth Token to exchange token for.
* @param addHeaders
* (object) Extra headers for the request.
*/
async getTokenFromOAuthToken(url, oauthToken, addHeaders = {}) {
this._log.debug("Beginning OAuth token exchange: " + url);
if (!oauthToken) {
throw new TokenServerClientError("oauthToken argument is not valid.");
}
return this._tokenServerExchangeRequest(
url,
`Bearer ${oauthToken}`,
addHeaders
);
},
/**
* Performs the exchange request to the token server to
* produce a token based on the authorizationHeader input.
*
* @param url
* (string) URL to fetch token from.
* @param authorizationHeader
* (string) The auth header string that populates the 'Authorization' header.
* @param addHeaders
* (object) Extra headers for the request.
*/
async _tokenServerExchangeRequest(url, authorizationHeader, addHeaders = {}) {
if (!url) {
throw new TokenServerClientError("url argument is not valid.");
}
if (!authorizationHeader) {
throw new TokenServerClientError(
"authorizationHeader argument is not valid."
);
}
let req = this.newRESTRequest(url);
req.setHeader("Accept", "application/json");
req.setHeader("Authorization", "BrowserID " + assertion);
req.setHeader("Authorization", authorizationHeader);
for (let header in addHeaders) {
req.setHeader(header, addHeaders[header]);

View File

@ -1525,8 +1525,14 @@ FxAccountsInternal.prototype = {
delete currentState.whenVerifiedDeferred;
},
// Does the actual fetch of an oauth token for getOAuthToken()
async _doTokenFetch(scopeString) {
/**
* Does the actual fetch of an oauth token for getOAuthToken()
* @param scopeString
* @param ttl
* @returns {Promise<string>}
* @private
*/
async _doTokenFetch(scopeString, ttl) {
// Ideally, we would auth this call directly with our `sessionToken` rather than
// going via a BrowserID assertion. Before we can do so we need to resolve some
// data-volume processing issues in the server-side FxA metrics pipeline.
@ -1536,7 +1542,8 @@ FxAccountsInternal.prototype = {
try {
let result = await this.fxAccountsOAuthGrantClient.getTokenFromAssertion(
assertion,
scopeString
scopeString,
ttl
);
token = result.access_token;
} catch (err) {
@ -1552,7 +1559,8 @@ FxAccountsInternal.prototype = {
assertion = await this.getAssertion(oAuthURL);
let result = await this.fxAccountsOAuthGrantClient.getTokenFromAssertion(
assertion,
scopeString
scopeString,
ttl
);
token = result.access_token;
}
@ -1597,7 +1605,7 @@ FxAccountsInternal.prototype = {
// We need to start a new fetch and stick the promise in our in-flight map
// and remove it when it resolves.
let promise = this._doTokenFetch(scopeString)
let promise = this._doTokenFetch(scopeString, options.ttl)
.then(token => {
// As a sanity check, ensure something else hasn't raced getting a token
// of the same scope. If something has we just make noise rather than

View File

@ -51,6 +51,7 @@ exports.ASSERTION_LIFETIME = 1000 * 3600 * 24 * 365 * 25; // 25 years
// period).
exports.ASSERTION_USE_PERIOD = 1000 * 60 * 5; // 5 minutes
exports.CERT_LIFETIME = 1000 * 3600 * 6; // 6 hours
exports.OAUTH_TOKEN_FOR_SYNC_LIFETIME_SECONDS = 3600 * 6; // 6 hours
exports.KEY_LIFETIME = 1000 * 3600 * 12; // 12 hours
// After we start polling for account verification, we stop polling when this

View File

@ -78,10 +78,11 @@ FxAccountsOAuthGrantClient.prototype = {
*
* @param {Object} assertion BrowserID assertion
* @param {String} scope OAuth scope
* @param {Number} ttl token time to live
* @return Promise
* Resolves: {Object} Object with access_token property
*/
getTokenFromAssertion(assertion, scope) {
getTokenFromAssertion(assertion, scope, ttl) {
if (!assertion) {
throw new Error("Missing 'assertion' parameter");
}
@ -93,6 +94,7 @@ FxAccountsOAuthGrantClient.prototype = {
client_id: this.parameters.client_id,
assertion,
response_type: "token",
ttl,
};
return this._createRequest(AUTH_ENDPOINT, "POST", params);

View File

@ -105,12 +105,15 @@ function MockFxAccountsOAuthGrantClient(activeTokens) {
MockFxAccountsOAuthGrantClient.prototype = {
serverURL: { href: "http://localhost" },
getTokenFromAssertion(assertion, scope) {
let token = "token" + this.numTokenFetches;
getTokenFromAssertion(assertion, scope, ttl) {
let token = `token${this.numTokenFetches}`;
if (ttl) {
token += `-ttl-${ttl}`;
}
this.numTokenFetches += 1;
this.activeTokens.add(token);
print("getTokenFromAssertion returning token", token);
return Promise.resolve({ access_token: token });
return Promise.resolve({ access_token: token, ttl });
},
};
@ -251,3 +254,10 @@ add_task(async function testTokenRaces() {
equal(oauthClient.activeTokens.size, 0);
ok(client.oauthDestroy.calledTwice);
});
add_task(async function testTokenTTL() {
// This tests the TTL option passed into the method
let fxa = await createMockFxA();
let token = await fxa.getOAuthToken({ scope: "test-ttl", ttl: 1000 });
equal(token, "token0-ttl-1000");
});

View File

@ -244,6 +244,15 @@ var configureFxAccountIdentity = function(
config.fxaccount.token.uid = config.username;
return config.fxaccount.token;
},
async getTokenFromOAuthToken(url, oauthToken) {
Assert.equal(
url,
Services.prefs.getStringPref("identity.sync.tokenserver.uri")
);
Assert.ok(oauthToken, "oauth token present");
config.fxaccount.token.uid = config.username;
return config.fxaccount.token;
},
};
authService._fxaService = fxa;
authService._tokenServerClient = mockTSC;

View File

@ -62,6 +62,12 @@ XPCOMUtils.defineLazyPreferenceGetter(
"services.sync.debug.ignoreCachedAuthCredentials"
);
XPCOMUtils.defineLazyPreferenceGetter(
this,
"USE_OAUTH_FOR_SYNC_TOKEN",
"identity.sync.useOAuthForSyncToken"
);
// FxAccountsCommon.js doesn't use a "namespace", so create one here.
var fxAccountsCommon = {};
ChromeUtils.import(
@ -396,16 +402,22 @@ this.BrowserIDManager.prototype = {
// Do the assertion/certificate/token dance, with a retry.
let getToken = async keys => {
this._log.info("Getting an assertion from", this._tokenServerUrl);
const audience = Services.io.newURI(this._tokenServerUrl).prePath;
const assertion = await fxa._internal.getAssertion(audience);
let token;
if (USE_OAUTH_FOR_SYNC_TOKEN) {
token = await this._fetchTokenUsingOAuth();
} else {
const audience = Services.io.newURI(this._tokenServerUrl).prePath;
const assertion = await fxa._internal.getAssertion(audience);
this._log.debug("Getting a token using an Assertion");
const headers = { "X-Client-State": keys.kXCS };
token = await this._tokenServerClient.getTokenFromBrowserIDAssertion(
this._tokenServerUrl,
assertion,
headers
);
}
this._log.debug("Getting a token");
const headers = { "X-Client-State": keys.kXCS };
const token = await this._tokenServerClient.getTokenFromBrowserIDAssertion(
this._tokenServerUrl,
assertion,
headers
);
this._log.trace("Successfully got a token");
return token;
};
@ -473,6 +485,32 @@ this.BrowserIDManager.prototype = {
}
},
/**
* Fetches an OAuth token using the OLD_SYNC scope and later exchanges it
* for a TokenServer token.
*
* @returns {Promise}
* @private
*/
async _fetchTokenUsingOAuth() {
this._log.debug("Getting a token using OAuth");
const fxa = this._fxaService;
const clientId = fxAccountsCommon.FX_OAUTH_CLIENT_ID;
const scope = fxAccountsCommon.SCOPE_OLD_SYNC;
const ttl = fxAccountsCommon.OAUTH_TOKEN_FOR_SYNC_LIFETIME_SECONDS;
const oauthToken = await fxa.getOAuthToken({ scope, ttl });
const scopedKeys = await fxa.keys.getScopedKeys(scope, clientId);
const key = scopedKeys[scope];
const headers = {
"X-KeyId": key.kid,
};
return this._tokenServerClient.getTokenFromOAuthToken(
this._tokenServerUrl,
oauthToken,
headers
);
},
// Returns a promise that is resolved with a valid token for the current
// user, or rejects if one can't be obtained.
// NOTE: This does all the authentication for Sync - it both sets the

View File

@ -33,6 +33,23 @@ const SECOND_MS = 1000;
const MINUTE_MS = SECOND_MS * 60;
const HOUR_MS = MINUTE_MS * 60;
const MOCK_TOKEN_RESPONSE = {
access_token:
"e3c5caf17f27a0d9e351926a928938b3737df43e91d4992a5a5fca9a7bdef8ba",
token_type: "bearer",
scope: "https://identity.mozilla.com/apps/oldsync",
expires_in: 3600 * 6,
auth_at: 1587762898,
};
const MOCK_SCOPED_KEY_RESPONSE = {
"https://identity.mozilla.com/apps/oldsync": {
identifier: "https://identity.mozilla.com/apps/oldsync",
keyRotationSecret:
"0000000000000000000000000000000000000000000000000000000000000000",
keyRotationTimestamp: 1510726317123,
},
};
var globalIdentityConfig = makeIdentityConfig();
var globalBrowseridManager = new BrowserIDManager();
configureFxAccountIdentity(globalBrowseridManager, globalIdentityConfig);
@ -53,30 +70,6 @@ MockFxAccountsClient.prototype = {
},
};
function MockFxAccounts() {
let fxa = new FxAccounts({
_now_is: Date.now(),
now() {
return this._now_is;
},
fxAccountsClient: new MockFxAccountsClient(),
});
fxa._internal.currentAccountState.getCertificate = function(
data,
keyPair,
mustBeValidUntil
) {
this.cert = {
validUntil: fxa._internal.now() + CERT_LIFETIME,
cert: "certificate",
};
return Promise.resolve(this.cert.cert);
};
return fxa;
}
add_test(function test_initial_state() {
_("Verify initial state");
Assert.ok(!globalBrowseridManager._token);
@ -91,6 +84,26 @@ add_task(async function test_initialialize() {
Assert.ok(globalBrowseridManager._hasValidToken());
});
add_task(async function test_initialialize_via_oauth_token() {
_("Verify start after fetching token using the oauth token flow");
Services.prefs.setBoolPref("identity.sync.useOAuthForSyncToken", true);
let browseridManager = new BrowserIDManager();
let identityConfig = makeIdentityConfig();
let fxaInternal = makeFxAccountsInternalMock(identityConfig);
configureFxAccountIdentity(browseridManager, identityConfig, fxaInternal);
browseridManager._fxaService._internal.initialize();
browseridManager._fxaService._internal.fxAccountsOAuthGrantClient.getTokenFromAssertion = () =>
Promise.resolve(MOCK_TOKEN_RESPONSE);
browseridManager._fxaService._internal._fxAccountsClient.getScopedKeyData = () =>
Promise.resolve(MOCK_SCOPED_KEY_RESPONSE);
await browseridManager._ensureValidToken();
Assert.ok(!!browseridManager._token);
Assert.ok(browseridManager._hasValidToken());
Services.prefs.setBoolPref("identity.sync.useOAuthForSyncToken", false);
});
add_task(async function test_initialializeWithAuthErrorAndDeletedAccount() {
_("Verify sync state with auth error + account deleted");