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