no bug - Correct some typos in the comments. a=typo-fix
[gecko.git] / services / fxaccounts / FxAccountsOAuth.sys.mjs
blobe8f186d1f79c544dd933b121250bfbfa22117f61
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 const lazy = {};
7 ChromeUtils.defineESModuleGetters(lazy, {
8   jwcrypto: "resource://services-crypto/jwcrypto.sys.mjs",
9 });
11 import {
12   FX_OAUTH_CLIENT_ID,
13   SCOPE_PROFILE,
14   SCOPE_PROFILE_WRITE,
15   SCOPE_OLD_SYNC,
16 } from "resource://gre/modules/FxAccountsCommon.sys.mjs";
18 const VALID_SCOPES = [SCOPE_PROFILE, SCOPE_PROFILE_WRITE, SCOPE_OLD_SYNC];
20 export const ERROR_INVALID_SCOPES = "INVALID_SCOPES";
21 export const ERROR_INVALID_STATE = "INVALID_STATE";
22 export const ERROR_SYNC_SCOPE_NOT_GRANTED = "ERROR_SYNC_SCOPE_NOT_GRANTED";
23 export const ERROR_NO_KEYS_JWE = "ERROR_NO_KEYS_JWE";
24 export const ERROR_OAUTH_FLOW_ABANDONED = "ERROR_OAUTH_FLOW_ABANDONED";
25 export const ERROR_INVALID_SCOPED_KEYS = "ERROR_INVALID_SCOPED_KEYS";
27 /**
28  * Handles all logic and state related to initializing, and completing OAuth flows
29  * with FxA
30  * It's possible to start multiple OAuth flow, but only one can be completed, and once one flow is completed
31  * all the other in-flight flows will be concluded, and attempting to complete those flows will result in errors.
32  */
33 export class FxAccountsOAuth {
34   #flow;
35   #fxaClient;
36   #fxaKeys;
37   /**
38    * Creates a new FxAccountsOAuth
39    *
40    * @param { Object } fxaClient: The fxa client used to send http request to the oauth server
41    */
42   constructor(fxaClient, fxaKeys) {
43     this.#flow = {};
44     this.#fxaClient = fxaClient;
45     this.#fxaKeys = fxaKeys;
46   }
48   /**
49    * Stores a flow in-memory
50    * @param { string } state: A base-64 URL-safe string represnting a random value created at the start of the flow
51    * @param { Object } value: The data needed to complete a flow, once the oauth code is available.
52    * in practice, `value` is:
53    *  - `verifier`: A base=64 URL-safe string representing the PKCE code verifier
54    *  - `key`: The private key need to decrypt the JWE we recieve from the auth server
55    *  - `requestedScopes`: The scopes the caller requested, meant to be compared against the scopes the server authorized
56    */
57   addFlow(state, value) {
58     this.#flow[state] = value;
59   }
61   /**
62    * Clears all started flows
63    */
64   clearAllFlows() {
65     this.#flow = {};
66   }
68   /*
69    * Gets a stored flow
70    * @param { string } state: The base-64 URL-safe state string that was created at the start of the flow
71    * @returns { Object }: The values initially stored when startign th eoauth flow
72    * in practice, the return value is:
73    *  - `verifier`: A base=64 URL-safe string representing the PKCE code verifier
74    *  - `key`: The private key need to decrypt the JWE we recieve from the auth server
75    *  - ``requestedScopes`: The scopes the caller requested, meant to be compared against the scopes the server authorized
76    */
77   getFlow(state) {
78     return this.#flow[state];
79   }
81   /* Returns the number of flows, used by tests
82    *
83    */
84   numOfFlows() {
85     return Object.keys(this.#flow).length;
86   }
88   /**
89    * Begins an OAuth flow, to be completed with a an OAuth code and state.
90    *
91    * This function stores needed information to complete the flow. You must call `completeOAuthFlow`
92    * on the same instance of `FxAccountsOAuth`, otherwise the completing of the oauth flow will fail.
93    *
94    * @param { string[] } scopes: The OAuth scopes the client should request from FxA
95    *
96    * @returns { Object }: Returns an object representing the query parameters that should be
97    *     added to the FxA authorization URL to initialize an oAuth flow.
98    *     In practice, the query parameters are:
99    *       - `client_id`: The OAuth client ID for Firefox Desktop
100    *       - `scope`: The scopes given by the caller, space seperated
101    *       - `action`: This will always be `email`
102    *       - `response_type`: This will always be `code`
103    *       - `access_type`: This will always be `offline`
104    *       - `state`: A URL-safe base-64 string randomly generated
105    *       - `code_challenge`: A URL-safe base-64 string representing the PKCE challenge
106    *       - `code_challenge_method`: This will always be `S256`
107    *          For more informatio about PKCE, read https://datatracker.ietf.org/doc/html/rfc7636
108    *       - `keys_jwk`: A URL-safe base-64 representing a JWK to be used as a public key by the server
109    *          to generate a JWE
110    */
111   async beginOAuthFlow(scopes) {
112     if (
113       !Array.isArray(scopes) ||
114       scopes.some(scope => !VALID_SCOPES.includes(scope))
115     ) {
116       throw new Error(ERROR_INVALID_SCOPES);
117     }
118     const queryParams = {
119       client_id: FX_OAUTH_CLIENT_ID,
120       action: "email",
121       response_type: "code",
122       access_type: "offline",
123       scope: scopes.join(" "),
124     };
126     // Generate a random, 16 byte value to represent a state that we verify
127     // once we complete the oauth flow, to ensure that we only conclude
128     // an oauth flow that we started
129     const state = new Uint8Array(16);
130     crypto.getRandomValues(state);
131     const stateB64 = ChromeUtils.base64URLEncode(state, { pad: false });
132     queryParams.state = stateB64;
134     // Generate a 43 byte code verifier for PKCE, in accordance with
135     // https://datatracker.ietf.org/doc/html/rfc7636#section-7.1 which recommends a
136     // 43-octet URL safe string
137     // The byte array is 32 bytes
138     const codeVerifier = new Uint8Array(32);
139     crypto.getRandomValues(codeVerifier);
140     // When base64 encoded, it is 43 bytes
141     const codeVerifierB64 = ChromeUtils.base64URLEncode(codeVerifier, {
142       pad: false,
143     });
144     const challenge = await crypto.subtle.digest(
145       "SHA-256",
146       new TextEncoder().encode(codeVerifierB64)
147     );
148     const challengeB64 = ChromeUtils.base64URLEncode(challenge, { pad: false });
149     queryParams.code_challenge = challengeB64;
150     queryParams.code_challenge_method = "S256";
152     // Generate a public, private key pair to be used during the oauth flow
153     // to encrypt scoped-keys as they roundtrip through the auth server
154     const ECDH_KEY = { name: "ECDH", namedCurve: "P-256" };
155     const key = await crypto.subtle.generateKey(ECDH_KEY, false, ["deriveKey"]);
156     const publicKey = await crypto.subtle.exportKey("jwk", key.publicKey);
157     const privateKey = key.privateKey;
159     // We encode the public key as URL-safe base64 to be included in the query parameters
160     const encodedPublicKey = ChromeUtils.base64URLEncode(
161       new TextEncoder().encode(JSON.stringify(publicKey)),
162       { pad: false }
163     );
164     queryParams.keys_jwk = encodedPublicKey;
166     // We store the state in-memory, to verify once the oauth flow is completed
167     this.addFlow(stateB64, {
168       key: privateKey,
169       verifier: codeVerifierB64,
170       requestedScopes: scopes.join(" "),
171     });
172     return queryParams;
173   }
175   /** Completes an OAuth flow and invalidates any other ongoing flows
176    * @param { string } sessionTokenHex: The session token encoded in hexadecimal
177    * @param { string } code: OAuth authorization code provided by running an OAuth flow
178    * @param { string } state: The state first provided by `beginOAuthFlow`, then roundtripped through the server
179    *
180    * @returns { Object }: Returns an object representing the result of completing the oauth flow.
181    *   The object includes the following:
182    *     - 'scopedKeys': The encryption keys provided by the server, already decrypted
183    *     - 'refreshToken': The refresh token provided by the server
184    *     - 'accessToken': The access token provided by the server
185    * */
186   async completeOAuthFlow(sessionTokenHex, code, state) {
187     const flow = this.getFlow(state);
188     if (!flow) {
189       throw new Error(ERROR_INVALID_STATE);
190     }
191     const { key, verifier, requestedScopes } = flow;
192     const { keys_jwe, refresh_token, access_token, scope } =
193       await this.#fxaClient.oauthToken(
194         sessionTokenHex,
195         code,
196         verifier,
197         FX_OAUTH_CLIENT_ID
198       );
199     if (
200       requestedScopes.includes(SCOPE_OLD_SYNC) &&
201       !scope.includes(SCOPE_OLD_SYNC)
202     ) {
203       throw new Error(ERROR_SYNC_SCOPE_NOT_GRANTED);
204     }
205     if (scope.includes(SCOPE_OLD_SYNC) && !keys_jwe) {
206       throw new Error(ERROR_NO_KEYS_JWE);
207     }
208     let scopedKeys;
209     if (keys_jwe) {
210       scopedKeys = JSON.parse(
211         new TextDecoder().decode(await lazy.jwcrypto.decryptJWE(keys_jwe, key))
212       );
213       if (!this.#fxaKeys.validScopedKeys(scopedKeys)) {
214         throw new Error(ERROR_INVALID_SCOPED_KEYS);
215       }
216     }
218     // We make sure no other flow snuck in, and completed before we did
219     if (!this.getFlow(state)) {
220       throw new Error(ERROR_OAUTH_FLOW_ABANDONED);
221     }
223     // Clear all flows, so any in-flight or future flows trigger an error as the browser
224     // would have been signed in
225     this.clearAllFlows();
226     return {
227       scopedKeys,
228       refreshToken: refresh_token,
229       accessToken: access_token,
230     };
231   }