Source: sessions/persistent-session.ts

  1. /**
  2. * @fileoverview A Persistent Box API Session.
  3. */
  4. // ------------------------------------------------------------------------------
  5. // Requirements
  6. // ------------------------------------------------------------------------------
  7. import assert from 'assert';
  8. import { Promise } from 'bluebird';
  9. import httpStatusCodes from 'http-status';
  10. import errors from '../util/errors';
  11. // ------------------------------------------------------------------------------
  12. // Typedefs
  13. // ------------------------------------------------------------------------------
  14. type TokenInfo = any /* FIXME */;
  15. type TokenStore = any /* FIXME */;
  16. type Config = any /* FIXME */;
  17. type TokenManager = any /* FIXME */;
  18. type TokenRequestOptions = Record<string, any> /* FIXME */;
  19. // ------------------------------------------------------------------------------
  20. // Private
  21. // ------------------------------------------------------------------------------
  22. /**
  23. * Validate that an object is a valid TokenInfo object
  24. *
  25. * @param {Object} obj The object to validate
  26. * @returns {boolean} True if the passed in object is a valid TokenInfo object that
  27. * has all the expected properties, false otherwise
  28. * @private
  29. */
  30. function isObjectValidTokenInfo(obj: Record<string, any>) {
  31. return Boolean(
  32. obj &&
  33. obj.accessToken &&
  34. obj.refreshToken &&
  35. obj.accessTokenTTLMS &&
  36. obj.acquiredAtMS
  37. );
  38. }
  39. /**
  40. * Validate that an object is a valid TokenStore object
  41. *
  42. * @param {Object} obj the object to validate
  43. * @returns {boolean} returns true if the passed in object is a valid TokenStore object that
  44. * has all the expected properties. false otherwise.
  45. * @private
  46. */
  47. function isObjectValidTokenStore(obj: Record<string, any>) {
  48. return Boolean(obj && obj.read && obj.write && obj.clear);
  49. }
  50. // ------------------------------------------------------------------------------
  51. // Public
  52. // ------------------------------------------------------------------------------
  53. /**
  54. * A Persistent API Session has the ability to refresh its access token once it becomes expired.
  55. * It takes in a full tokenInfo object for authentication. It can detect when its tokens have
  56. * expired and will request new, valid tokens if needed. It can also interface with a token
  57. * data-store if one is provided.
  58. *
  59. * Persistent API Session a good choice for long-running applications or web servers that
  60. * must remember users across sessions.
  61. *
  62. * @param {TokenInfo} tokenInfo A valid TokenInfo object. Will throw if improperly formatted.
  63. * @param {TokenStore} [tokenStore] A valid TokenStore object. Will throw if improperly formatted.
  64. * @param {Config} config The SDK configuration options
  65. * @param {TokenManager} tokenManager The token manager
  66. * @constructor
  67. */
  68. class PersistentSession {
  69. _config: Config;
  70. _refreshPromise: Promise<any> | null;
  71. _tokenManager: TokenManager;
  72. _tokenStore: TokenStore;
  73. _tokenInfo: TokenInfo;
  74. constructor(
  75. tokenInfo: TokenInfo,
  76. tokenStore: TokenStore,
  77. config: Config,
  78. tokenManager: TokenManager
  79. ) {
  80. this._config = config;
  81. this._tokenManager = tokenManager;
  82. // Keeps track of if tokens are currently being refreshed
  83. this._refreshPromise = null;
  84. // Set valid PersistentSession credentials. Throw if expected credentials are invalid or not given.
  85. assert(
  86. isObjectValidTokenInfo(tokenInfo),
  87. 'tokenInfo is improperly formatted. Properties required: accessToken, refreshToken, accessTokenTTLMS and acquiredAtMS.'
  88. );
  89. this._setTokenInfo(tokenInfo);
  90. // If tokenStore was provided, set the persistent data & current store operations
  91. if (tokenStore) {
  92. assert(
  93. isObjectValidTokenStore(tokenStore),
  94. 'Token store provided but is improperly formatted. Methods required: read(), write(), clear().'
  95. );
  96. this._tokenStore = Promise.promisifyAll(tokenStore);
  97. }
  98. }
  99. /**
  100. * Sets all relevant token info for this client.
  101. *
  102. * @param {TokenInfo} tokenInfo A valid TokenInfo object.
  103. * @returns {void}
  104. * @private
  105. */
  106. _setTokenInfo(tokenInfo: TokenStore) {
  107. this._tokenInfo = {
  108. accessToken: tokenInfo.accessToken,
  109. refreshToken: tokenInfo.refreshToken,
  110. accessTokenTTLMS: tokenInfo.accessTokenTTLMS,
  111. acquiredAtMS: tokenInfo.acquiredAtMS,
  112. };
  113. }
  114. /**
  115. * Attempts to refresh tokens for the client.
  116. * Will use the Box refresh token grant to complete the refresh. On refresh failure, we'll
  117. * check the token store for more recently updated tokens and load them if found. Otherwise
  118. * an error will be propagated.
  119. *
  120. * @param {TokenRequestOptions} [options] - Sets optional behavior for the token grant
  121. * @returns {Promise<string>} Promise resolving to the access token
  122. * @private
  123. */
  124. _refreshTokens(options?: TokenRequestOptions) {
  125. // If not already refreshing, kick off a token refresh request and set a lock so that additional
  126. // client requests don't try as well
  127. if (!this._refreshPromise) {
  128. this._refreshPromise = this._tokenManager
  129. .getTokensRefreshGrant(this._tokenInfo.refreshToken, options)
  130. .catch((err: any) => {
  131. // If we got an error response from Box API, but it was 400 invalid_grant, it indicates we may have just
  132. // made the request with an invalidated refresh token. Since only a max of 2 refresh tokens can be valid
  133. // at any point in time, and a horizontally scaled app could have multiple Node instances running in parallel,
  134. // it is possible to hit cases where too many servers all refresh a user's tokens at once
  135. // and cause this server's token to become invalidated. However, the user should still be alive, but
  136. // we'll need to check the central data store for the latest valid tokens that some other server in the app
  137. // cluster would have received. So, instead pull tokens from the central store and attempt to use them.
  138. if (
  139. err.statusCode === httpStatusCodes.BAD_REQUEST &&
  140. this._tokenStore
  141. ) {
  142. var invalidGrantError = err;
  143. // Check the tokenStore to see if tokens have been updated recently. If they have, then another
  144. // instance of the session may have already refreshed the user tokens, which would explain why
  145. // we couldn't refresh.
  146. return this._tokenStore
  147. .readAsync()
  148. .catch((e: any) => errors.unwrapAndThrow(e))
  149. .then((storeTokenInfo: TokenStore) => {
  150. // if the tokens we got from the central store are the same as the tokens we made the failed request with
  151. // already, then we can be sure that no other servers have valid tokens for this server either.
  152. // Thus, this user truly has an expired refresh token. So, propagate an "Expired Tokens" error.
  153. if (
  154. !storeTokenInfo ||
  155. storeTokenInfo.refreshToken === this._tokenInfo.refreshToken
  156. ) {
  157. throw errors.buildAuthError(invalidGrantError.response);
  158. }
  159. // Propagate the fresh tokens that we found in the session
  160. return storeTokenInfo;
  161. });
  162. }
  163. // Box API returned a permanent error that is not retryable and we can't recover.
  164. // We have no usable tokens for the user and no way to refresh them - propagate a permanent error.
  165. throw err;
  166. })
  167. .then((tokenInfo: TokenInfo) => {
  168. // Success! We got back a TokenInfo object from the API.
  169. // If we have a token store, we'll write it there now before finishing up the request.
  170. if (this._tokenStore) {
  171. return this._tokenStore
  172. .writeAsync(tokenInfo)
  173. .catch((e: any) => errors.unwrapAndThrow(e))
  174. .then(() => tokenInfo);
  175. }
  176. // If no token store, Set and propagate the access token immediately
  177. return tokenInfo;
  178. })
  179. .then((tokenInfo: TokenInfo) => {
  180. // Set and propagate the new access token
  181. this._setTokenInfo(tokenInfo);
  182. return tokenInfo.accessToken;
  183. })
  184. .catch((err: any) => this.handleExpiredTokensError(err))
  185. .finally(() => {
  186. // Refresh complete, clear promise
  187. this._refreshPromise = null;
  188. });
  189. }
  190. return this._refreshPromise as Promise<any>;
  191. }
  192. // ------------------------------------------------------------------------------
  193. // Public Instance
  194. // ------------------------------------------------------------------------------
  195. /**
  196. * Returns the clients access token.
  197. *
  198. * If tokens don't yet exist, first attempt to retrieve them.
  199. * If tokens are expired, first attempt to refresh them.
  200. *
  201. * @param {TokenRequestOptions} [options] - Sets optional behavior for the token grant
  202. * @returns {Promise<string>} Promise resolving to the access token
  203. */
  204. getAccessToken(options?: TokenRequestOptions) {
  205. // If our tokens are not fresh, we need to refresh them
  206. const expirationBuffer = this._config.expiredBufferMS;
  207. if (
  208. !this._tokenManager.isAccessTokenValid(this._tokenInfo, expirationBuffer)
  209. ) {
  210. return this._refreshTokens(options);
  211. }
  212. // Current access token is still valid. Return it.
  213. return Promise.resolve(this._tokenInfo.accessToken);
  214. }
  215. /**
  216. * Revokes the session's tokens. If the session has a refresh token we'll use that,
  217. * since it is more likely to be up to date. Otherwise, we'll revoke the accessToken.
  218. * Revoking either one will disable the other as well.
  219. *
  220. * @param {TokenRequestOptions} [options] - Sets optional behavior for the token grant
  221. * @returns {Promise} Promise that resolves when the revoke succeeds
  222. */
  223. revokeTokens(options?: TokenRequestOptions) {
  224. return this._tokenManager.revokeTokens(
  225. this._tokenInfo.refreshToken,
  226. options
  227. );
  228. }
  229. /**
  230. * Exchange the client access token for one with lower scope
  231. * @param {string|string[]} scopes The scope(s) requested for the new token
  232. * @param {string} [resource] The absolute URL of an API resource to scope the new token to
  233. * @param {Object} [options] - Optional parameters
  234. * @param {TokenRequestOptions} [options.tokenRequestOptions] - Sets optional behavior for the token grant
  235. * @returns {void}
  236. */
  237. exchangeToken(
  238. scopes: string | string[],
  239. resource?: string,
  240. options?: {
  241. tokenRequestOptions?: TokenRequestOptions;
  242. }
  243. ) {
  244. return this.getAccessToken(options).then((accessToken) =>
  245. this._tokenManager.exchangeToken(accessToken, scopes, resource, options)
  246. );
  247. }
  248. /**
  249. * Handle an an "Expired Tokens" Error. If our tokens are expired, we need to clear the token
  250. * store (if present) before continuing.
  251. *
  252. * @param {Errors~ExpiredTokensError} err An "expired tokens" error including information
  253. * about the request/response.
  254. * @returns {Promise<Error>} Promise resolving to an error. This will
  255. * usually be the original response error, but could an error from trying to access the
  256. * token store as well.
  257. */
  258. handleExpiredTokensError(err: any /* FIXME */) {
  259. if (!this._tokenStore) {
  260. return Promise.resolve(err);
  261. }
  262. // If a token store is available, clear the store and throw either error
  263. // eslint-disable-next-line promise/no-promise-in-callback
  264. return this._tokenStore
  265. .clearAsync()
  266. .catch((e: any) => errors.unwrapAndThrow(e))
  267. .then(() => {
  268. throw err;
  269. });
  270. }
  271. }
  272. /**
  273. * @module box-node-sdk/lib/sessions/persistent-session
  274. * @see {@Link PersistentSession}
  275. */
  276. export = PersistentSession;