FxAccountsProfileClient.sys.mjs (8109B)
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 /** 6 * A client to fetch profile information for a Firefox Account. 7 */ 8 9 import { 10 ERRNO_NETWORK, 11 ERRNO_PARSE, 12 ERRNO_UNKNOWN_ERROR, 13 ERROR_CODE_METHOD_NOT_ALLOWED, 14 ERROR_MSG_METHOD_NOT_ALLOWED, 15 ERROR_NETWORK, 16 ERROR_PARSE, 17 ERROR_UNKNOWN, 18 log, 19 SCOPE_PROFILE, 20 SCOPE_PROFILE_WRITE, 21 } from "resource://gre/modules/FxAccountsCommon.sys.mjs"; 22 23 import { getFxAccountsSingleton } from "resource://gre/modules/FxAccounts.sys.mjs"; 24 25 const fxAccounts = getFxAccountsSingleton(); 26 import { RESTRequest } from "resource://services-common/rest.sys.mjs"; 27 28 /** 29 * Create a new FxAccountsProfileClient to be able to fetch Firefox Account profile information. 30 * 31 * @param {object} options Options 32 * @param {string} options.serverURL 33 * The URL of the profile server to query. 34 * Example: https://profile.accounts.firefox.com/v1 35 * @class 36 */ 37 export var FxAccountsProfileClient = function (options) { 38 if (!options?.serverURL) { 39 throw new Error("Missing 'serverURL' configuration option"); 40 } 41 42 this.fxai = options.fxai || fxAccounts._internal; 43 44 this.serverURL = URL.parse(options.serverURL); 45 if (!this.serverURL) { 46 throw new Error("Invalid 'serverURL'"); 47 } 48 log.debug("FxAccountsProfileClient: Initialized"); 49 }; 50 51 FxAccountsProfileClient.prototype = { 52 /** 53 * {URL} 54 * The server to fetch profile information from. 55 */ 56 serverURL: null, 57 58 /** 59 * Interface for making remote requests. 60 */ 61 _Request: RESTRequest, 62 63 /** 64 * Remote request helper which abstracts authentication away. 65 * 66 * @param {string} path 67 * Profile server path, i.e "/profile". 68 * @param {string} [method] 69 * Type of request, e.g. "GET". 70 * @param {string} [etag] 71 * Optional ETag used for caching purposes. 72 * @param {object} [body] 73 * Optional request body, to be sent as application/json. 74 * @return Promise 75 * Resolves: {body: Object, etag: Object} Successful response from the Profile server. 76 * Rejects: {FxAccountsProfileClientError} Profile client error. 77 * @private 78 */ 79 async _createRequest(path, method = "GET", etag = null, body = null) { 80 method = method.toUpperCase(); 81 let token = await this._getTokenForRequest(method); 82 try { 83 return await this._rawRequest(path, method, token, etag, body); 84 } catch (ex) { 85 if (!(ex instanceof FxAccountsProfileClientError) || ex.code != 401) { 86 throw ex; 87 } 88 // it's an auth error - assume our token expired and retry. 89 log.info( 90 "Fetching the profile returned a 401 - revoking our token and retrying" 91 ); 92 await this.fxai.removeCachedOAuthToken({ token }); 93 token = await this._getTokenForRequest(method); 94 // and try with the new token - if that also fails then we fail after 95 // revoking the token. 96 try { 97 return await this._rawRequest(path, method, token, etag, body); 98 } catch (ex) { 99 if (!(ex instanceof FxAccountsProfileClientError) || ex.code != 401) { 100 throw ex; 101 } 102 log.info( 103 "Retry fetching the profile still returned a 401 - revoking our token and failing" 104 ); 105 await this.fxai.removeCachedOAuthToken({ token }); 106 throw ex; 107 } 108 } 109 }, 110 111 /** 112 * Helper to get an OAuth token for a request. 113 * 114 * OAuth tokens are cached, so it's fine to call this for each request. 115 * 116 * @param {string} [method] 117 * Type of request, i.e "GET". 118 * @return Promise 119 * Resolves: Object containing "scope", "token" and "key" properties 120 * Rejects: {FxAccountsProfileClientError} Profile client error. 121 * @private 122 */ 123 async _getTokenForRequest(method) { 124 let scope = SCOPE_PROFILE; 125 if (method === "POST") { 126 scope = SCOPE_PROFILE_WRITE; 127 } 128 return this.fxai.getOAuthToken({ scope }); 129 }, 130 131 /** 132 * Remote "raw" request helper - doesn't handle auth errors and tokens. 133 * 134 * @param {string} path 135 * Profile server path, i.e "/profile". 136 * @param {string} method 137 * Type of request, i.e "GET". 138 * @param {string} token 139 * @param {string} etag 140 * @param {object} payload 141 * The payload of the request, if any. 142 * @return Promise 143 * Resolves: {body: Object, etag: Object} Successful response from the Profile server 144 or null if 304 is hit (same ETag). 145 * Rejects: {FxAccountsProfileClientError} Profile client error. 146 * @private 147 */ 148 async _rawRequest(path, method, token, etag = null, payload = null) { 149 let profileDataUrl = this.serverURL + path; 150 let request = new this._Request(profileDataUrl); 151 152 request.setHeader("Authorization", "Bearer " + token); 153 request.setHeader("Accept", "application/json"); 154 if (etag) { 155 request.setHeader("If-None-Match", etag); 156 } 157 158 if (method != "GET" && method != "POST") { 159 // method not supported 160 throw new FxAccountsProfileClientError({ 161 error: ERROR_NETWORK, 162 errno: ERRNO_NETWORK, 163 code: ERROR_CODE_METHOD_NOT_ALLOWED, 164 message: ERROR_MSG_METHOD_NOT_ALLOWED, 165 }); 166 } 167 try { 168 await request.dispatch(method, payload); 169 } catch (error) { 170 throw new FxAccountsProfileClientError({ 171 error: ERROR_NETWORK, 172 errno: ERRNO_NETWORK, 173 message: error.toString(), 174 }); 175 } 176 177 let body = null; 178 try { 179 if (request.response.status == 304) { 180 return null; 181 } 182 body = JSON.parse(request.response.body); 183 } catch (e) { 184 throw new FxAccountsProfileClientError({ 185 error: ERROR_PARSE, 186 errno: ERRNO_PARSE, 187 code: request.response.status, 188 message: request.response.body, 189 }); 190 } 191 192 // "response.success" means status code is 200 193 if (!request.response.success) { 194 throw new FxAccountsProfileClientError({ 195 error: body.error || ERROR_UNKNOWN, 196 errno: body.errno || ERRNO_UNKNOWN_ERROR, 197 code: request.response.status, 198 message: body.message || body, 199 }); 200 } 201 return { 202 body, 203 etag: request.response.headers.etag, 204 }; 205 }, 206 207 /** 208 * Retrieve user's profile from the server 209 * 210 * @param {string} [etag] 211 * Optional ETag used for caching purposes. (may generate a 304 exception) 212 * @return Promise 213 * Resolves: {body: Object, etag: Object} Successful response from the '/profile' endpoint. 214 * Rejects: {FxAccountsProfileClientError} profile client error. 215 */ 216 fetchProfile(etag) { 217 log.debug("FxAccountsProfileClient: Requested profile"); 218 return this._createRequest("/profile", "GET", etag); 219 }, 220 }; 221 222 /** 223 * Normalized profile client errors 224 * 225 * @param {object} [details] 226 * Error details object 227 * @param {number} [details.code] 228 * Error code 229 * @param {number} [details.errno] 230 * Error number 231 * @param {string} [details.error] 232 * Error description 233 * @param {string | null} [details.message] 234 * Error message 235 * @class 236 */ 237 export var FxAccountsProfileClientError = function (details) { 238 details = details || {}; 239 240 this.name = "FxAccountsProfileClientError"; 241 this.code = details.code || null; 242 this.errno = details.errno || ERRNO_UNKNOWN_ERROR; 243 this.error = details.error || ERROR_UNKNOWN; 244 this.message = details.message || null; 245 }; 246 247 /** 248 * Returns error object properties 249 * 250 * @returns {{name: *, code: *, errno: *, error: *, message: *}} 251 * @private 252 */ 253 FxAccountsProfileClientError.prototype._toStringFields = function () { 254 return { 255 name: this.name, 256 code: this.code, 257 errno: this.errno, 258 error: this.error, 259 message: this.message, 260 }; 261 }; 262 263 /** 264 * String representation of a profile client error 265 * 266 * @returns {string} 267 */ 268 FxAccountsProfileClientError.prototype.toString = function () { 269 return this.name + "(" + JSON.stringify(this._toStringFields()) + ")"; 270 };