tokenserverclient.sys.mjs (12991B)
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 file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { Log } from "resource://gre/modules/Log.sys.mjs"; 6 7 import { RESTRequest } from "resource://services-common/rest.sys.mjs"; 8 import { Observers } from "resource://services-common/observers.sys.mjs"; 9 10 const PREF_LOG_LEVEL = "services.common.log.logger.tokenserverclient"; 11 12 /** 13 * Represents a TokenServerClient error that occurred on the client. 14 * 15 * This is the base type for all errors raised by client operations. 16 * 17 * @param message 18 * (string) Error message. 19 */ 20 export function TokenServerClientError(message) { 21 this.name = "TokenServerClientError"; 22 this.message = message || "Client error."; 23 // Without explicitly setting .stack, all stacks from these errors will point 24 // to the "new Error()" call a few lines down, which isn't helpful. 25 this.stack = Error().stack; 26 } 27 28 TokenServerClientError.prototype = new Error(); 29 TokenServerClientError.prototype.constructor = TokenServerClientError; 30 TokenServerClientError.prototype._toStringFields = function () { 31 return { message: this.message }; 32 }; 33 TokenServerClientError.prototype.toString = function () { 34 return this.name + "(" + JSON.stringify(this._toStringFields()) + ")"; 35 }; 36 TokenServerClientError.prototype.toJSON = function () { 37 let result = this._toStringFields(); 38 result.name = this.name; 39 return result; 40 }; 41 42 /** 43 * Represents a TokenServerClient error that occurred in the network layer. 44 * 45 * @param error 46 * The underlying error thrown by the network layer. 47 */ 48 export function TokenServerClientNetworkError(error) { 49 this.name = "TokenServerClientNetworkError"; 50 this.error = error; 51 this.stack = Error().stack; 52 } 53 54 TokenServerClientNetworkError.prototype = new TokenServerClientError(); 55 TokenServerClientNetworkError.prototype.constructor = 56 TokenServerClientNetworkError; 57 TokenServerClientNetworkError.prototype._toStringFields = function () { 58 return { error: this.error }; 59 }; 60 61 /** 62 * Represents a TokenServerClient error that occurred on the server. 63 * 64 * This type will be encountered for all non-200 response codes from the 65 * server. The type of error is strongly enumerated and is stored in the 66 * `cause` property. This property can have the following string values: 67 * 68 * invalid-credentials -- A token could not be obtained because 69 * the credentials presented by the client were invalid. 70 * 71 * unknown-service -- The requested service was not found. 72 * 73 * malformed-request -- The server rejected the request because it 74 * was invalid. If you see this, code in this file is likely wrong. 75 * 76 * malformed-response -- The response from the server was not what was 77 * expected. 78 * 79 * general -- A general server error has occurred. Clients should 80 * interpret this as an opaque failure. 81 * 82 * @param message 83 * (string) Error message. 84 */ 85 export function TokenServerClientServerError(message, cause = "general") { 86 this.now = new Date().toISOString(); // may be useful to diagnose time-skew issues. 87 this.name = "TokenServerClientServerError"; 88 this.message = message || "Server error."; 89 this.cause = cause; 90 this.stack = Error().stack; 91 } 92 93 TokenServerClientServerError.prototype = new TokenServerClientError(); 94 TokenServerClientServerError.prototype.constructor = 95 TokenServerClientServerError; 96 97 TokenServerClientServerError.prototype._toStringFields = function () { 98 let fields = { 99 now: this.now, 100 message: this.message, 101 cause: this.cause, 102 }; 103 if (this.response) { 104 fields.response_body = this.response.body; 105 fields.response_headers = this.response.headers; 106 fields.response_status = this.response.status; 107 } 108 return fields; 109 }; 110 111 /** 112 * Represents a client to the Token Server. 113 * 114 * http://docs.services.mozilla.com/token/index.html 115 * 116 * The Token Server was designed to support obtaining tokens for arbitrary apps by 117 * constructing URI paths of the form <app>/<app_version>. In practice this was 118 * never used and it only supports an <app> value of `sync`, and the API presented 119 * here reflects that. 120 * 121 * Areas to Improve: 122 * 123 * - The server sends a JSON response on error. The client does not currently 124 * parse this. It might be convenient if it did. 125 * - Currently most non-200 status codes are rolled into one error type. It 126 * might be helpful if callers had a richer API that communicated who was 127 * at fault (e.g. differentiating a 503 from a 401). 128 */ 129 export function TokenServerClient() { 130 this._log = Log.repository.getLogger("Services.Common.TokenServerClient"); 131 this._log.manageLevelFromPref(PREF_LOG_LEVEL); 132 } 133 134 TokenServerClient.prototype = { 135 /** 136 * Logger instance. 137 */ 138 _log: null, 139 140 /** 141 * Obtain a token from a provided OAuth token against a specific URL. 142 * 143 * This asynchronously obtains the token. 144 * It returns a Promise that resolves or rejects: 145 * 146 * Rejects with: 147 * (TokenServerClientError) If no token could be obtained, this 148 * will be a TokenServerClientError instance describing why. The 149 * type seen defines the type of error encountered. If an HTTP response 150 * was seen, a RESTResponse instance will be stored in the `response` 151 * property of this object. If there was no error and a token is 152 * available, this will be null. 153 * 154 * Resolves with: 155 * (map) On success, this will be a map containing the results from 156 * the server. If there was an error, this will be null. The map has the 157 * following properties: 158 * 159 * id (string) HTTP MAC public key identifier. 160 * key (string) HTTP MAC shared symmetric key. 161 * endpoint (string) URL where service can be connected to. 162 * uid (string) user ID for requested service. 163 * duration (string) the validity duration of the issued token. 164 * 165 * Example Usage 166 * ------------- 167 * 168 * let client = new TokenServerClient(); 169 * let access_token = getOAuthAccessTokenFromSomewhere(); 170 * let url = "https://token.services.mozilla.com/1.0/sync/2.0"; 171 * 172 * try { 173 * const result = await client.getTokenUsingOAuth(url, access_token); 174 * let {id, key, uid, endpoint, duration} = result; 175 * // Do stuff with data and carry on. 176 * } catch (error) { 177 * // Handle errors. 178 * } 179 * Obtain a token from a provided OAuth token against a specific URL. 180 * 181 * @param url 182 * (string) URL to fetch token from. 183 * @param oauthToken 184 * (string) FxA OAuth Token to exchange token for. 185 * @param addHeaders 186 * (object) Extra headers for the request. 187 */ 188 async getTokenUsingOAuth(url, oauthToken, addHeaders = {}) { 189 this._log.debug("Beginning OAuth token exchange: " + url); 190 191 if (!oauthToken) { 192 throw new TokenServerClientError("oauthToken argument is not valid."); 193 } 194 195 return this._tokenServerExchangeRequest( 196 url, 197 `Bearer ${oauthToken}`, 198 addHeaders 199 ); 200 }, 201 202 /** 203 * Performs the exchange request to the token server to 204 * produce a token based on the authorizationHeader input. 205 * 206 * @param url 207 * (string) URL to fetch token from. 208 * @param authorizationHeader 209 * (string) The auth header string that populates the 'Authorization' header. 210 * @param addHeaders 211 * (object) Extra headers for the request. 212 */ 213 async _tokenServerExchangeRequest(url, authorizationHeader, addHeaders = {}) { 214 if (!url) { 215 throw new TokenServerClientError("url argument is not valid."); 216 } 217 218 if (!authorizationHeader) { 219 throw new TokenServerClientError( 220 "authorizationHeader argument is not valid." 221 ); 222 } 223 224 let req = this.newRESTRequest(url); 225 req.setHeader("Accept", "application/json"); 226 req.setHeader("Authorization", authorizationHeader); 227 228 for (let header in addHeaders) { 229 req.setHeader(header, addHeaders[header]); 230 } 231 let response; 232 try { 233 response = await req.get(); 234 } catch (err) { 235 throw new TokenServerClientNetworkError(err); 236 } 237 238 try { 239 return this._processTokenResponse(response); 240 } catch (ex) { 241 if (ex instanceof TokenServerClientServerError) { 242 throw ex; 243 } 244 this._log.warn("Error processing token server response", ex); 245 let error = new TokenServerClientError(ex); 246 error.response = response; 247 throw error; 248 } 249 }, 250 251 /** 252 * Handler to process token request responses. 253 * 254 * @param response 255 * RESTResponse from token HTTP request. 256 */ 257 _processTokenResponse(response) { 258 this._log.debug("Got token response: " + response.status); 259 260 // Responses should *always* be JSON, even in the case of 4xx and 5xx 261 // errors. If we don't see JSON, the server is likely very unhappy. 262 let ct = response.headers["content-type"] || ""; 263 if (ct != "application/json" && !ct.startsWith("application/json;")) { 264 this._log.warn("Did not receive JSON response. Misconfigured server?"); 265 this._log.debug("Content-Type: " + ct); 266 this._log.debug("Body: " + response.body); 267 268 let error = new TokenServerClientServerError( 269 "Non-JSON response.", 270 "malformed-response" 271 ); 272 error.response = response; 273 throw error; 274 } 275 276 let result; 277 try { 278 result = JSON.parse(response.body); 279 } catch (ex) { 280 this._log.warn("Invalid JSON returned by server: " + response.body); 281 let error = new TokenServerClientServerError( 282 "Malformed JSON.", 283 "malformed-response" 284 ); 285 error.response = response; 286 throw error; 287 } 288 289 // Any response status can have X-Backoff or X-Weave-Backoff headers. 290 this._maybeNotifyBackoff(response, "x-weave-backoff"); 291 this._maybeNotifyBackoff(response, "x-backoff"); 292 293 // The service shouldn't have any 3xx, so we don't need to handle those. 294 if (response.status != 200) { 295 // We /should/ have a Cornice error report in the JSON. We log that to 296 // help with debugging. 297 if ("errors" in result) { 298 // This could throw, but this entire function is wrapped in a try. If 299 // the server is sending something not an array of objects, it has 300 // failed to keep its contract with us and there is little we can do. 301 for (let error of result.errors) { 302 this._log.info("Server-reported error: " + JSON.stringify(error)); 303 } 304 } 305 306 let error = new TokenServerClientServerError(); 307 error.response = response; 308 309 if (response.status == 400) { 310 error.message = "Malformed request."; 311 error.cause = "malformed-request"; 312 } else if (response.status == 401) { 313 // Cause can be invalid-credentials, invalid-timestamp, or 314 // invalid-generation. 315 error.message = "Authentication failed."; 316 error.cause = result.status; 317 } else if (response.status == 404) { 318 error.message = "Unknown service."; 319 error.cause = "unknown-service"; 320 } 321 322 // A Retry-After header should theoretically only appear on a 503, but 323 // we'll look for it on any error response. 324 this._maybeNotifyBackoff(response, "retry-after"); 325 326 throw error; 327 } 328 329 for (let k of ["id", "key", "api_endpoint", "uid", "duration"]) { 330 if (!(k in result)) { 331 let error = new TokenServerClientServerError( 332 "Expected key not present in result: " + k 333 ); 334 error.cause = "malformed-response"; 335 error.response = response; 336 throw error; 337 } 338 } 339 340 this._log.debug("Successful token response"); 341 return { 342 id: result.id, 343 key: result.key, 344 endpoint: result.api_endpoint, 345 uid: result.uid, 346 duration: result.duration, 347 hashed_fxa_uid: result.hashed_fxa_uid, 348 node_type: result.node_type, 349 }; 350 }, 351 352 /* 353 * The prefix used for all notifications sent by this module. This 354 * allows the handler of notifications to be sure they are handling 355 * notifications for the service they expect. 356 * 357 * If not set, no notifications will be sent. 358 */ 359 observerPrefix: null, 360 361 // Given an optional header value, notify that a backoff has been requested. 362 _maybeNotifyBackoff(response, headerName) { 363 if (!this.observerPrefix) { 364 return; 365 } 366 let headerVal = response.headers[headerName]; 367 if (!headerVal) { 368 return; 369 } 370 let backoffInterval; 371 try { 372 backoffInterval = parseInt(headerVal, 10); 373 } catch (ex) { 374 this._log.error( 375 "TokenServer response had invalid backoff value in '" + 376 headerName + 377 "' header: " + 378 headerVal 379 ); 380 return; 381 } 382 Observers.notify( 383 this.observerPrefix + ":backoff:interval", 384 backoffInterval 385 ); 386 }, 387 388 // override points for testing. 389 newRESTRequest(url) { 390 return new RESTRequest(url); 391 }, 392 };