summaryrefslogtreecommitdiffstats
path: root/services/fxaccounts/FxAccountsOAuth.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'services/fxaccounts/FxAccountsOAuth.sys.mjs')
-rw-r--r--services/fxaccounts/FxAccountsOAuth.sys.mjs224
1 files changed, 224 insertions, 0 deletions
diff --git a/services/fxaccounts/FxAccountsOAuth.sys.mjs b/services/fxaccounts/FxAccountsOAuth.sys.mjs
new file mode 100644
index 0000000000..1935decff2
--- /dev/null
+++ b/services/fxaccounts/FxAccountsOAuth.sys.mjs
@@ -0,0 +1,224 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * 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,
+ SCOPE_PROFILE_WRITE,
+ SCOPE_OLD_SYNC,
+} from "resource://gre/modules/FxAccountsCommon.sys.mjs";
+
+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
+ * It's possible to start multiple OAuth flow, but only one can be completed, and once one flow is completed
+ * all the other in-flight flows will be concluded, and attempting to complete those flows will result in errors.
+ */
+export class FxAccountsOAuth {
+ #flow;
+ #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;
+ }
+
+ /**
+ * Stores a flow in-memory
+ * @param { string } state: A base-64 URL-safe string represnting a random value created at the start of the flow
+ * @param { Object } value: The data needed to complete a flow, once the oauth code is available.
+ * in practice, `value` is:
+ * - `verifier`: A base=64 URL-safe string representing the PKCE code verifier
+ * - `key`: The private key need to decrypt the JWE we recieve from the auth server
+ * - `requestedScopes`: The scopes the caller requested, meant to be compared against the scopes the server authorized
+ */
+ addFlow(state, value) {
+ this.#flow[state] = value;
+ }
+
+ /**
+ * Clears all started flows
+ */
+ clearAllFlows() {
+ this.#flow = {};
+ }
+
+ /*
+ * Gets a stored flow
+ * @param { string } state: The base-64 URL-safe state string that was created at the start of the flow
+ * @returns { Object }: The values initially stored when startign th eoauth flow
+ * in practice, the return value is:
+ * - `verifier`: A base=64 URL-safe string representing the PKCE code verifier
+ * - `key`: The private key need to decrypt the JWE we recieve from the auth server
+ * - ``requestedScopes`: The scopes the caller requested, meant to be compared against the scopes the server authorized
+ */
+ getFlow(state) {
+ 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.
+ *
+ * This function stores needed information to complete the flow. You must call `completeOAuthFlow`
+ * on the same instance of `FxAccountsOAuth`, otherwise the completing of the oauth flow will fail.
+ *
+ * @param { string[] } scopes: The OAuth scopes the client should request from FxA
+ *
+ * @returns { Object }: Returns an object representing the query parameters that should be
+ * added to the FxA authorization URL to initialize an oAuth flow.
+ * In practice, the query parameters are:
+ * - `client_id`: The OAuth client ID for Firefox Desktop
+ * - `scope`: The scopes given by the caller, space seperated
+ * - `action`: This will always be `email`
+ * - `response_type`: This will always be `code`
+ * - `access_type`: This will always be `offline`
+ * - `state`: A URL-safe base-64 string randomly generated
+ * - `code_challenge`: A URL-safe base-64 string representing the PKCE challenge
+ * - `code_challenge_method`: This will always be `S256`
+ * For more informatio about PKCE, read https://datatracker.ietf.org/doc/html/rfc7636
+ * - `keys_jwk`: A URL-safe base-64 representing a JWK to be used as a public key by the server
+ * to generate a JWE
+ */
+ async beginOAuthFlow(scopes) {
+ if (
+ !Array.isArray(scopes) ||
+ scopes.some(scope => !VALID_SCOPES.includes(scope))
+ ) {
+ throw new Error(ERROR_INVALID_SCOPES);
+ }
+ const queryParams = {
+ client_id: FX_OAUTH_CLIENT_ID,
+ action: "email",
+ response_type: "code",
+ access_type: "offline",
+ scope: scopes.join(" "),
+ };
+
+ // Generate a random, 16 byte value to represent a state that we verify
+ // once we complete the oauth flow, to ensure that we only conclude
+ // an oauth flow that we started
+ const state = new Uint8Array(16);
+ crypto.getRandomValues(state);
+ const stateB64 = ChromeUtils.base64URLEncode(state, { pad: false });
+ queryParams.state = stateB64;
+
+ // Generate a 43 byte code verifier for PKCE, in accordance with
+ // https://datatracker.ietf.org/doc/html/rfc7636#section-7.1 which recommends a
+ // 43-octet URL safe string
+ const codeVerifier = new Uint8Array(43);
+ crypto.getRandomValues(codeVerifier);
+ const codeVerifierB64 = ChromeUtils.base64URLEncode(codeVerifier, {
+ pad: false,
+ });
+ const challenge = await crypto.subtle.digest(
+ "SHA-256",
+ new TextEncoder().encode(codeVerifierB64)
+ );
+ const challengeB64 = ChromeUtils.base64URLEncode(challenge, { pad: false });
+ queryParams.code_challenge = challengeB64;
+ queryParams.code_challenge_method = "S256";
+
+ // Generate a public, private key pair to be used during the oauth flow
+ // to encrypt scoped-keys as they roundtrip through the auth server
+ const ECDH_KEY = { name: "ECDH", namedCurve: "P-256" };
+ const key = await crypto.subtle.generateKey(ECDH_KEY, true, ["deriveKey"]);
+ const publicKey = await crypto.subtle.exportKey("jwk", key.publicKey);
+ const privateKey = key.privateKey;
+
+ // We encode the public key as URL-safe base64 to be included in the query parameters
+ const encodedPublicKey = ChromeUtils.base64URLEncode(
+ new TextEncoder().encode(JSON.stringify(publicKey)),
+ { pad: false }
+ );
+ queryParams.keys_jwk = encodedPublicKey;
+
+ // We store the state in-memory, to verify once the oauth flow is completed
+ this.addFlow(stateB64, {
+ key: privateKey,
+ verifier: codeVerifierB64,
+ requestedScopes: scopes.join(" "),
+ });
+ return queryParams;
+ }
+
+ /** 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
+ *
+ * @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(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(
+ sessionTokenHex,
+ 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,
+ };
+ }
+}