Skip to content

Commit

Permalink
Bug 1866143: Adds completing the oauth flow. r=markh,skhamis
Browse files Browse the repository at this point in the history
  • Loading branch information
Tarik Eshaq committed Dec 12, 2023
1 parent d8ee361 commit 95cdb4d
Show file tree
Hide file tree
Showing 3 changed files with 285 additions and 12 deletions.
19 changes: 18 additions & 1 deletion services/fxaccounts/FxAccountsClient.sys.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,24 @@ FxAccountsClient.prototype = {
}
return this._request("/oauth/authorization", "POST", credentials, body);
},

/**
* Exchanges an OAuth authorization code with a refresh token, access tokens and an optional JWE representing scoped keys
*
* @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) {
const body = {
grant_type: "authorization_code",
code,
client_id: clientId,
code_verifier: verifier,
};
return this._request("/oauth/token", "POST", null, body);
},
/**
* Destroy an OAuth access token or refresh token.
*
Expand Down
85 changes: 80 additions & 5 deletions services/fxaccounts/FxAccountsOAuth.sys.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
jwcrypto: "resource://services-crypto/jwcrypto.sys.mjs",
});

import {
FX_OAUTH_CLIENT_ID,
SCOPE_PROFILE,
Expand All @@ -11,6 +17,12 @@ import {

const VALID_SCOPES = [SCOPE_PROFILE, SCOPE_PROFILE_WRITE, SCOPE_OLD_SYNC];

export const ERROR_INVALID_SCOPES = "INVALID_SCOPES";
export const ERROR_INVALID_STATE = "INVALID_STATE";
export const ERROR_SYNC_SCOPE_NOT_GRANTED = "ERROR_SYNC_SCOPE_NOT_GRANTED";
export const ERROR_NO_KEYS_JWE = "ERROR_NO_KEYS_JWE";
export const ERROR_OAUTH_FLOW_ABANDONED = "ERROR_OAUTH_FLOW_ABANDONED";

/**
* Handles all logic and state related to initializing, and completing OAuth flows
* with FxA
Expand All @@ -19,8 +31,15 @@ const VALID_SCOPES = [SCOPE_PROFILE, SCOPE_PROFILE_WRITE, SCOPE_OLD_SYNC];
*/
export class FxAccountsOAuth {
#flow;
constructor() {
#fxaClient;
/**
* Creates a new FxAccountsOAuth
*
* @param { Object } fxaClient: The fxa client used to send http request to the oauth server
*/
constructor(fxaClient) {
this.#flow = {};
this.#fxaClient = fxaClient;
}

/**
Expand Down Expand Up @@ -56,6 +75,13 @@ export class FxAccountsOAuth {
return this.#flow[state];
}

/* Returns the number of flows, used by tests
*
*/
numOfFlows() {
return Object.keys(this.#flow).length;
}

/**
* Begins an OAuth flow, to be completed with a an OAuth code and state.
*
Expand All @@ -81,10 +107,10 @@ export class FxAccountsOAuth {
*/
async beginOAuthFlow(scopes) {
if (
!Array.isArray(scopes) &&
scopes.some(scope => !VALID_SCOPES.contains(scope))
!Array.isArray(scopes) ||
scopes.some(scope => !VALID_SCOPES.includes(scope))
) {
throw new Error("Invalid scopes");
throw new Error(ERROR_INVALID_SCOPES);
}
const queryParams = {
client_id: FX_OAUTH_CLIENT_ID,
Expand Down Expand Up @@ -136,8 +162,57 @@ export class FxAccountsOAuth {
this.addFlow(stateB64, {
key: privateKey,
verifier: codeVerifierB64,
requestedScopes: scopes,
requestedScopes: scopes.join(" "),
});
return queryParams;
}

/** Completes an OAuth flow and invalidates any other ongoing flows
* @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
*
* @returns { Object }: Returns an object representing the result of completing the oauth flow.
* The object includes the following:
* - 'scopedKeys': The encryption keys provided by the server, already decrypted
* - 'refreshToken': The refresh token provided by the server
* - 'accessToken': The access token provided by the server
* */
async completeOAuthFlow(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);
if (
requestedScopes.includes(SCOPE_OLD_SYNC) &&
!scope.includes(SCOPE_OLD_SYNC)
) {
throw new Error(ERROR_SYNC_SCOPE_NOT_GRANTED);
}
if (scope.includes(SCOPE_OLD_SYNC) && !keys_jwe) {
throw new Error(ERROR_NO_KEYS_JWE);
}
let scopedKeys;
if (keys_jwe) {
scopedKeys = JSON.parse(
new TextDecoder().decode(await lazy.jwcrypto.decryptJWE(keys_jwe, key))
);
}

// We make sure no other flow snuck in, and completed before we did
if (!this.getFlow(state)) {
throw new Error(ERROR_OAUTH_FLOW_ABANDONED);
}

// Clear all flows, so any in-flight or future flows trigger an error as the browser
// would have been signed in
this.clearAllFlows();
return {
scopedKeys,
refreshToken: refresh_token,
accessToken: access_token,
};
}
}
193 changes: 187 additions & 6 deletions services/fxaccounts/tests/xpcshell/test_oauth_flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@

"use strict";

const { FxAccountsOAuth } = ChromeUtils.importESModule(
const {
FxAccountsOAuth,
ERROR_INVALID_SCOPES,
ERROR_INVALID_STATE,
ERROR_SYNC_SCOPE_NOT_GRANTED,
ERROR_NO_KEYS_JWE,
ERROR_OAUTH_FLOW_ABANDONED,
} = ChromeUtils.importESModule(
"resource://gre/modules/FxAccountsOAuth.sys.mjs"
);

Expand All @@ -25,14 +32,14 @@ add_task(function test_begin_oauth_flow() {
try {
await oauth.beginOAuthFlow("foo,fi,fum", "foo");
Assert.fail("Should have thrown error, scopes must be an array");
} catch {
// OK
} 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 {
// OK
} catch (e) {
Assert.equal(e.message, ERROR_INVALID_SCOPES);
}
});
add_task(async function test_begin_oauth_flow_ok() {
Expand Down Expand Up @@ -83,6 +90,180 @@ add_task(function test_begin_oauth_flow() {
Assert.equal(new TextDecoder().decode(decrypted), plaintext);

// Finally, we verify that we stored the requested scopes
Assert.deepEqual(oauthFlow.requestedScopes, 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";
try {
await oauth.completeOAuthFlow(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_OLD_SYNC];
const queryParams = await oauth.beginOAuthFlow(scopes);
try {
await oauth.completeOAuthFlow("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_OLD_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);
try {
await oauth.completeOAuthFlow("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_OLD_SYNC];
const oauthCode = "fake oauth code";
const plainTextScopedKeys = {
kid: "fake key id",
k: "fake key",
kty: "oct",
};
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 = {};
// Then, we initialize our oauth object with the given client and begin a new flow
const oauth = new FxAccountsOAuth(fxaClient);
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 = (code, verifier, clientId) => {
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(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(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);
});
});

0 comments on commit 95cdb4d

Please sign in to comment.