Source: token-manager.ts

  1. /**
  2. * @fileoverview Token Manager
  3. */
  4. // ------------------------------------------------------------------------------
  5. // Requirements
  6. // ------------------------------------------------------------------------------
  7. import Promise from 'bluebird';
  8. import httpStatusCodes from 'http-status';
  9. import jwt from 'jsonwebtoken';
  10. import { v4 as uuidv4 } from 'uuid';
  11. import APIRequestManager from './api-request-manager';
  12. import errors from './util/errors';
  13. import getRetryTimeout from './util/exponential-backoff';
  14. // ------------------------------------------------------------------------------
  15. // Typedefs and Callbacks
  16. // ------------------------------------------------------------------------------
  17. type Config = Record<string, any> /* FIXME */;
  18. /**
  19. * Token request options. Set by the consumer to add/modify the params sent to the
  20. * request.
  21. *
  22. * @typedef {Object} TokenRequestOptions
  23. * @property {string} [ip] The IP Address of the requesting user. This IP will be reflected in authentication
  24. * notification emails sent to your users on login. Defaults to the IP address of the
  25. * server requesting the tokens.
  26. */
  27. type TokenRequestOptions = {
  28. ip?: string;
  29. };
  30. /**
  31. * Parameters for creating a token using a Box shared link via token exchange
  32. * @typedef {Object} SharedLinkParams
  33. * @property {string} url Shared link URL
  34. */
  35. type SharedLinkParams = {
  36. url: string;
  37. };
  38. /**
  39. * Parameters for creating an actor token via token exchange
  40. * @typedef {Object} ActorParams
  41. * @property {string} id The external identifier for the actor
  42. * @property {string} name The display name of the actor
  43. */
  44. type ActorParams = {
  45. id: string;
  46. name: string;
  47. };
  48. /**
  49. * An object representing all token information for a single Box user.
  50. *
  51. * @typedef {Object} TokenInfo
  52. * @property {string} accessToken The API access token. Used to authenticate API requests to a certain
  53. * user and/or application.
  54. * @property {int} acquiredAtMS The time that the tokens were acquired.
  55. * @property {int} accessTokenTTLMS The TTL of the access token. Can be used with acquiredAtMS to
  56. * calculate if the current access token has expired.
  57. * @property {string} [refreshToken] The API refresh token is a Longer-lasting than an access token, and can
  58. * be used to gain a new access token if the current access token becomes
  59. * expired. Grants like the 'client credentials' grant don't return a
  60. * refresh token, and have no refresh capabilities.
  61. */
  62. type TokenInfo = {
  63. accessToken: string;
  64. acquiredAtMS: number;
  65. accessTokenTTLMS: number;
  66. refreshToken?: string;
  67. };
  68. /**
  69. * Determines whether a JWT auth error can be retried
  70. * @param {Error} err The JWT auth error
  71. * @returns {boolean} True if the error is retryable
  72. */
  73. function isJWTAuthErrorRetryable(err: any /* FIXME */) {
  74. if (
  75. err.authExpired &&
  76. err.response.headers.date &&
  77. (err.response.body.error_description.indexOf('exp') > -1 ||
  78. err.response.body.error_description.indexOf('jti') > -1)
  79. ) {
  80. return true;
  81. } else if (err.statusCode === 429 || err.statusCode >= 500) {
  82. return true;
  83. }
  84. return false;
  85. }
  86. // ------------------------------------------------------------------------------
  87. // Constants
  88. // ------------------------------------------------------------------------------
  89. /**
  90. * Collection of grant types that can be used to acquire tokens via OAuth2
  91. *
  92. * @readonly
  93. * @enum {string}
  94. */
  95. var grantTypes = {
  96. AUTHORIZATION_CODE: 'authorization_code',
  97. REFRESH_TOKEN: 'refresh_token',
  98. CLIENT_CREDENTIALS: 'client_credentials',
  99. JWT: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
  100. TOKEN_EXCHANGE: 'urn:ietf:params:oauth:grant-type:token-exchange',
  101. };
  102. /**
  103. * Collection of paths to interact with Box OAuth2 tokening system
  104. *
  105. * @readonly
  106. * @enum {string}
  107. */
  108. enum tokenPaths {
  109. ROOT = '/oauth2',
  110. GET = '/token',
  111. REVOKE = '/revoke',
  112. }
  113. // Timer used to track elapsed time starting with executing an async request and ending with emitting the response.
  114. var asyncRequestTimer: any /* FIXME */;
  115. // The XFF header label - Used to give the API better information for uploads, rate-limiting, etc.
  116. const HEADER_XFF = 'X-Forwarded-For';
  117. const ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token';
  118. const ACTOR_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id_token';
  119. const BOX_JWT_AUDIENCE = 'https://api.box.com/oauth2/token';
  120. // ------------------------------------------------------------------------------
  121. // Private
  122. // ------------------------------------------------------------------------------
  123. /**
  124. * Parse the response body to create a new TokenInfo object.
  125. *
  126. * @param {Object} grantResponseBody - (Request lib) response body containing granted token info from API
  127. * @returns {TokenInfo} A TokenInfo object.
  128. * @private
  129. */
  130. function getTokensFromGrantResponse(
  131. grantResponseBody: Record<string, any> /* FIXME */
  132. ) {
  133. return {
  134. // Set the access token & refresh token (if passed)
  135. accessToken: grantResponseBody.access_token,
  136. refreshToken: grantResponseBody.refresh_token,
  137. // Box API sends back expires_in in seconds, we convert to ms for consistency of keeping all time in ms
  138. accessTokenTTLMS: parseInt(grantResponseBody.expires_in, 10) * 1000,
  139. acquiredAtMS: Date.now(),
  140. };
  141. }
  142. /**
  143. * Determines if a given string could represent an authorization code or token.
  144. *
  145. * @param {string} codeOrToken The code or token to check.
  146. * @returns {boolean} True if codeOrToken is valid, false if not.
  147. * @private
  148. */
  149. function isValidCodeOrToken(codeOrToken: string) {
  150. return typeof codeOrToken === 'string' && codeOrToken.length > 0;
  151. }
  152. /**
  153. * Determines if a token grant response is valid
  154. *
  155. * @param {string} grantType the type of token grant
  156. * @param {Object} responseBody the body of the response to check
  157. * @returns {boolean} True if response body has expected fields, false if not.
  158. * @private
  159. */
  160. function isValidTokenResponse(
  161. grantType: string,
  162. responseBody: Record<string, any> /* FIXME */
  163. ) {
  164. if (!isValidCodeOrToken(responseBody.access_token)) {
  165. return false;
  166. }
  167. if (typeof responseBody.expires_in !== 'number') {
  168. return false;
  169. }
  170. // Check the refresh_token for certain types of grants
  171. if (grantType === 'authorization_code' || grantType === 'refresh_token') {
  172. if (!isValidCodeOrToken(responseBody.refresh_token)) {
  173. return false;
  174. }
  175. }
  176. return true;
  177. }
  178. // ------------------------------------------------------------------------------
  179. // Public
  180. // ------------------------------------------------------------------------------
  181. /**
  182. * Manager for API access abd refresh tokens
  183. *
  184. * @param {Config} config The config object
  185. * @param {APIRequestManager} requestManager The API Request Manager
  186. * @constructor
  187. */
  188. class TokenManager {
  189. config: Config;
  190. requestManager: APIRequestManager;
  191. oauthBaseURL: string;
  192. constructor(config: Config, requestManager: APIRequestManager) {
  193. this.config = config;
  194. this.oauthBaseURL = config.apiRootURL + tokenPaths.ROOT;
  195. this.requestManager = requestManager;
  196. }
  197. /**
  198. * Given a TokenInfo object, returns whether its access token is expired. An access token is considered
  199. * expired once its TTL surpasses the current time outside of the given buffer. This is a public method so
  200. * that other modules may check the validity of their tokens.
  201. *
  202. * @param {TokenInfo} tokenInfo the token info to be written
  203. * @param {int} [bufferMS] An optional buffer we'd like to test against. The greater this buffer, the more aggressively
  204. * we'll call a token invalid.
  205. * @returns {boolean} True if token is valid outside of buffer, otherwise false
  206. */
  207. isAccessTokenValid(tokenInfo: TokenInfo, bufferMS?: number) {
  208. if (
  209. typeof tokenInfo.acquiredAtMS === 'undefined' ||
  210. typeof tokenInfo.accessTokenTTLMS === 'undefined'
  211. ) {
  212. return false;
  213. }
  214. bufferMS = bufferMS || 0;
  215. var expireTime =
  216. tokenInfo.acquiredAtMS + tokenInfo.accessTokenTTLMS - bufferMS;
  217. return expireTime > Date.now();
  218. }
  219. /**
  220. * Acquires OAuth2 tokens using a grant type (authorization_code, password, refresh_token)
  221. *
  222. * @param {Object} formParams - should contain all params expected by Box OAuth2 token endpoint
  223. * @param {TokenRequestOptions} [options] - Sets optional behavior for the token grant, null for default behavior
  224. * @returns {Promise<TokenInfo>} Promise resolving to the token info
  225. * @private
  226. */
  227. getTokens(
  228. formParams: Record<string, any>,
  229. options?: TokenRequestOptions | null
  230. ) {
  231. var params = {
  232. method: 'POST',
  233. url: this.oauthBaseURL + tokenPaths.GET,
  234. headers: {} as Record<string, any>,
  235. form: formParams,
  236. };
  237. options = options || {};
  238. // add in app-specific id and secret to auth with Box
  239. params.form.client_id = this.config.clientID;
  240. params.form.client_secret = this.config.clientSecret;
  241. if (options.ip) {
  242. params.headers[HEADER_XFF] = options.ip;
  243. }
  244. return this.requestManager.makeRequest(params).then((
  245. response: any /* FIXME */
  246. ) => {
  247. // Response Error: The API is telling us that we attempted an invalid token grant. This
  248. // means that our refresh token or auth code has exipred, so propagate an "Expired Tokens"
  249. // error.
  250. if (
  251. response.body &&
  252. response.body.error &&
  253. response.body.error === 'invalid_grant'
  254. ) {
  255. var errDescription = response.body.error_description;
  256. var message = errDescription
  257. ? `Auth Error: ${errDescription}`
  258. : undefined;
  259. throw errors.buildAuthError(response, message);
  260. }
  261. // Unexpected Response: If the token request couldn't get a valid response, then we're
  262. // out of options. Build an "Unexpected Response" error and propagate it out for the
  263. // consumer to handle.
  264. if (
  265. response.statusCode !== httpStatusCodes.OK ||
  266. response.body instanceof Buffer
  267. ) {
  268. throw errors.buildUnexpectedResponseError(response);
  269. }
  270. // Check to see if token response is valid in case the API returns us a 200 with a malformed token
  271. if (!isValidTokenResponse(formParams.grant_type, response.body)) {
  272. throw errors.buildResponseError(
  273. response,
  274. 'Token format from response invalid'
  275. );
  276. }
  277. // Got valid token response. Parse out the TokenInfo and propagate it back.
  278. return getTokensFromGrantResponse(response.body);
  279. });
  280. }
  281. /**
  282. * Acquires token info using an authorization code
  283. *
  284. * @param {string} authorizationCode - authorization code issued by Box
  285. * @param {TokenRequestOptions} [options] - Sets optional behavior for the token grant
  286. * @returns {Promise<TokenInfo>} Promise resolving to the token info
  287. */
  288. getTokensAuthorizationCodeGrant(
  289. authorizationCode: string,
  290. options?: TokenRequestOptions
  291. ) {
  292. if (!isValidCodeOrToken(authorizationCode)) {
  293. return Promise.reject(new Error('Invalid authorization code.'));
  294. }
  295. var params = {
  296. grant_type: grantTypes.AUTHORIZATION_CODE,
  297. code: authorizationCode,
  298. };
  299. return this.getTokens(params, options);
  300. }
  301. /**
  302. * Acquires token info using the client credentials grant.
  303. *
  304. * @param {TokenRequestOptions} [options] - Sets optional behavior for the token grant
  305. * @returns {Promise<TokenInfo>} Promise resolving to the token info
  306. */
  307. getTokensClientCredentialsGrant(options?: TokenRequestOptions) {
  308. var params = {
  309. grant_type: grantTypes.CLIENT_CREDENTIALS,
  310. box_subject_type: this.config.boxSubjectType,
  311. box_subject_id: this.config.boxSubjectId
  312. };
  313. return this.getTokens(params, options);
  314. }
  315. /**
  316. * Refreshes the access and refresh tokens for a given refresh token.
  317. *
  318. * @param {string} refreshToken - A valid OAuth refresh token
  319. * @param {TokenRequestOptions} [options] - Sets optional behavior for the token grant
  320. * @returns {Promise<TokenInfo>} Promise resolving to the token info
  321. */
  322. getTokensRefreshGrant(refreshToken: string, options?: TokenRequestOptions) {
  323. if (!isValidCodeOrToken(refreshToken)) {
  324. return Promise.reject(new Error('Invalid refresh token.'));
  325. }
  326. var params = {
  327. grant_type: grantTypes.REFRESH_TOKEN,
  328. refresh_token: refreshToken,
  329. };
  330. return this.getTokens(params, options);
  331. }
  332. /**
  333. * Gets tokens for enterprise administration of app users
  334. * @param {string} type The type of token to create, "user" or "enterprise"
  335. * @param {string} id The ID of the enterprise to generate a token for
  336. * @param {TokenRequestOptions} [options] - Sets optional behavior for the token grant
  337. * @returns {Promise<TokenInfo>} Promise resolving to the token info
  338. */
  339. getTokensJWTGrant(type: string, id: string, options?: TokenRequestOptions) {
  340. if (!this.config.appAuth || !this.config.appAuth.keyID) {
  341. return Promise.reject(
  342. new Error('Must provide app auth configuration to use JWT Grant')
  343. );
  344. }
  345. var claims = {
  346. exp: Math.floor(Date.now() / 1000) + this.config.appAuth.expirationTime,
  347. box_sub_type: type,
  348. };
  349. var jwtOptions = {
  350. algorithm: this.config.appAuth.algorithm,
  351. audience: BOX_JWT_AUDIENCE,
  352. subject: id,
  353. issuer: this.config.clientID,
  354. jwtid: uuidv4(),
  355. noTimestamp: !this.config.appAuth.verifyTimestamp,
  356. keyid: this.config.appAuth.keyID,
  357. };
  358. var keyParams = {
  359. key: this.config.appAuth.privateKey,
  360. passphrase: this.config.appAuth.passphrase,
  361. };
  362. var assertion;
  363. try {
  364. assertion = jwt.sign(claims, keyParams, jwtOptions);
  365. } catch (jwtErr) {
  366. return Promise.reject(jwtErr);
  367. }
  368. var params = {
  369. grant_type: grantTypes.JWT,
  370. assertion,
  371. };
  372. // Start the request timer immediately before executing the async request
  373. asyncRequestTimer = process.hrtime();
  374. return this.getTokens(params, options).catch((err) =>
  375. this.retryJWTGrant(claims, jwtOptions, keyParams, params, options, err, 0)
  376. );
  377. }
  378. /**
  379. * Attempt a retry if possible and create a new JTI claim. If the request hasn't exceeded it's maximum number of retries,
  380. * re-execute the request (after the retry interval). Otherwise, propagate a new error.
  381. *
  382. * @param {Object} claims - JTI claims object
  383. * @param {Object} [jwtOptions] - JWT options for the signature
  384. * @param {Object} keyParams - Key JWT parameters object that contains the private key and the passphrase
  385. * @param {Object} params - Should contain all params expected by Box OAuth2 token endpoint
  386. * @param {TokenRequestOptions} [options] - Sets optional behavior for the token grant
  387. * @param {Error} error - Error from the previous JWT request
  388. * @param {int} numRetries - Number of retries attempted
  389. * @returns {Promise<TokenInfo>} Promise resolving to the token info
  390. */
  391. // eslint-disable-next-line max-params
  392. retryJWTGrant(
  393. claims: any /* FIXME */,
  394. jwtOptions: any /* FIXME */,
  395. keyParams: any /* FIXME */,
  396. params: any /* FIXME */,
  397. options: TokenRequestOptions | undefined,
  398. error: any /* FIXME */,
  399. numRetries: number
  400. ): any /* FIXME */ {
  401. if (
  402. numRetries < this.config.numMaxRetries &&
  403. isJWTAuthErrorRetryable(error)
  404. ) {
  405. var retryTimeoutinSeconds;
  406. numRetries += 1;
  407. // If the retry strategy is defined, then use it to determine the time (in ms) until the next retry or to
  408. // propagate an error to the user.
  409. if (this.config.retryStrategy) {
  410. // Get the total elapsed time so far since the request was executed
  411. var totalElapsedTime = process.hrtime(asyncRequestTimer);
  412. var totalElapsedTimeMS =
  413. totalElapsedTime[0] * 1000 + totalElapsedTime[1] / 1000000;
  414. var retryOptions = {
  415. error,
  416. numRetryAttempts: numRetries,
  417. numMaxRetries: this.config.numMaxRetries,
  418. retryIntervalMS: this.config.retryIntervalMS,
  419. totalElapsedTimeMS,
  420. };
  421. retryTimeoutinSeconds = this.config.retryStrategy(retryOptions);
  422. // If the retry strategy doesn't return a number/time in ms, then propagate the response error to the user.
  423. // However, if the retry strategy returns its own error, this will be propagated to the user instead.
  424. if (typeof retryTimeoutinSeconds !== 'number') {
  425. if (retryTimeoutinSeconds instanceof Error) {
  426. error = retryTimeoutinSeconds;
  427. }
  428. throw error;
  429. }
  430. } else if (
  431. error.hasOwnProperty('response') &&
  432. error.response.hasOwnProperty('headers') &&
  433. error.response.headers.hasOwnProperty('retry-after')
  434. ) {
  435. retryTimeoutinSeconds = error.response.headers['retry-after'];
  436. } else {
  437. retryTimeoutinSeconds = Math.ceil(getRetryTimeout(numRetries, this.config.retryIntervalMS) / 1000);
  438. }
  439. var time = Math.floor(Date.now() / 1000);
  440. if (error.response.headers.date) {
  441. time = Math.floor(Date.parse(error.response.headers.date) / 1000);
  442. }
  443. // Add length of retry timeout to current expiration time to calculate the expiration time for the JTI claim.
  444. claims.exp = Math.ceil(time + this.config.appAuth.expirationTime + retryTimeoutinSeconds);
  445. jwtOptions.jwtid = uuidv4();
  446. try {
  447. params.assertion = jwt.sign(claims, keyParams, jwtOptions);
  448. } catch (jwtErr) {
  449. throw jwtErr;
  450. }
  451. return Promise.delay(retryTimeoutinSeconds).then(() => {
  452. // Start the request timer immediately before executing the async request
  453. asyncRequestTimer = process.hrtime();
  454. return this.getTokens(params, options).catch((err) =>
  455. this.retryJWTGrant(
  456. claims,
  457. jwtOptions,
  458. keyParams,
  459. params,
  460. options,
  461. err,
  462. numRetries
  463. )
  464. );
  465. });
  466. } else if (numRetries >= this.config.numMaxRetries) {
  467. error.maxRetriesExceeded = true;
  468. }
  469. throw error;
  470. }
  471. /**
  472. * Exchange a valid access token for one with a lower scope, or delegated to
  473. * an external user identifier.
  474. *
  475. * @param {string} accessToken - The valid access token to exchange
  476. * @param {string|string[]} scopes - The scope(s) of the new access token
  477. * @param {string} [resource] - The absolute URL of an API resource to restrict the new token to
  478. * @param {Object} [options] - Optional parameters
  479. * @param {TokenRequestOptions} [options.tokenRequestOptions] - Sets optional behavior for the token grant
  480. * @param {ActorParams} [options.actor] - Optional actor parameters for creating annotator tokens
  481. * @param {SharedLinkParams} [options.sharedLink] - Optional shared link parameters for creating tokens using shared links
  482. * @returns {Promise<TokenInfo>} Promise resolving to the new token info
  483. */
  484. exchangeToken(
  485. accessToken: string,
  486. scopes: string | string[],
  487. resource?: string,
  488. options?: {
  489. tokenRequestOptions?: TokenRequestOptions;
  490. actor?: ActorParams;
  491. sharedLink?: SharedLinkParams;
  492. }
  493. ) {
  494. var params: {
  495. grant_type: string;
  496. subject_token_type: string;
  497. subject_token: string;
  498. scope: string;
  499. resource?: string;
  500. box_shared_link?: string;
  501. actor_token?: string;
  502. actor_token_type?: string;
  503. } = {
  504. grant_type: grantTypes.TOKEN_EXCHANGE,
  505. subject_token_type: ACCESS_TOKEN_TYPE,
  506. subject_token: accessToken,
  507. scope: typeof scopes === 'string' ? scopes : scopes.join(' '),
  508. };
  509. if (resource) {
  510. params.resource = resource;
  511. }
  512. if (options && options.sharedLink) {
  513. params.box_shared_link = options.sharedLink.url;
  514. }
  515. if (options && options.actor) {
  516. var payload = {
  517. iss: this.config.clientID,
  518. sub: options.actor.id,
  519. aud: BOX_JWT_AUDIENCE,
  520. box_sub_type: 'external',
  521. name: options.actor.name,
  522. };
  523. var jwtOptions = {
  524. algorithm: 'none',
  525. expiresIn: '1m',
  526. noTimestamp: true,
  527. jwtid: uuidv4(),
  528. };
  529. var token;
  530. try {
  531. token = jwt.sign(payload, 'UNUSED', jwtOptions as any /* FIXME */);
  532. } catch (jwtError) {
  533. return Promise.reject(jwtError);
  534. }
  535. params.actor_token = token;
  536. params.actor_token_type = ACTOR_TOKEN_TYPE;
  537. }
  538. return this.getTokens(
  539. params,
  540. options && options.tokenRequestOptions
  541. ? options.tokenRequestOptions
  542. : null
  543. );
  544. }
  545. /**
  546. * Revokes a token pair associated with a given access or refresh token.
  547. *
  548. * @param {string} token - A valid access or refresh token to revoke
  549. * @param {TokenRequestOptions} [options] - Sets optional behavior for the token grant
  550. * @returns {Promise} Promise resolving if the revoke succeeds
  551. */
  552. revokeTokens(token: string, options?: TokenRequestOptions) {
  553. var params: {
  554. method: string;
  555. url: string;
  556. form: Record<string, string>;
  557. headers?: Record<string, string>;
  558. } = {
  559. method: 'POST',
  560. url: this.oauthBaseURL + tokenPaths.REVOKE,
  561. form: {
  562. token,
  563. client_id: this.config.clientID,
  564. client_secret: this.config.clientSecret,
  565. },
  566. };
  567. if (options && options.ip) {
  568. params.headers = {};
  569. params.headers[HEADER_XFF] = options.ip;
  570. }
  571. return this.requestManager.makeRequest(params);
  572. }
  573. }
  574. /**
  575. * Provides interactions with Box OAuth2 tokening system.
  576. *
  577. * @module box-node-sdk/lib/token-manager
  578. */
  579. export = TokenManager;