token.js 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. /**
  2. * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  4. */
  5. /**
  6. * @module cloud-services-core/token
  7. */
  8. /* globals XMLHttpRequest, setTimeout, clearTimeout, atob */
  9. import mix from '@ckeditor/ckeditor5-utils/src/mix';
  10. import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
  11. import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
  12. const DEFAULT_OPTIONS = { autoRefresh: true };
  13. const DEFAULT_TOKEN_REFRESH_TIMEOUT_TIME = 3600000;
  14. /**
  15. * Class representing the token used for communication with CKEditor Cloud Services.
  16. * Value of the token is retrieving from the specified URL and is refreshed every 1 hour by default.
  17. *
  18. * @mixes ObservableMixin
  19. */
  20. class Token {
  21. /**
  22. * Creates `Token` instance.
  23. * Method `init` should be called after using the constructor or use `create` method instead.
  24. *
  25. * @param {String|Function} tokenUrlOrRefreshToken Endpoint address to download the token or a callback that provides the token. If the
  26. * value is a function it has to match the {@link module:cloud-services-core/token~refreshToken} interface.
  27. * @param {Object} options
  28. * @param {String} [options.initValue] Initial value of the token.
  29. * @param {Boolean} [options.autoRefresh=true] Specifies whether to start the refresh automatically.
  30. */
  31. constructor( tokenUrlOrRefreshToken, options = DEFAULT_OPTIONS ) {
  32. if ( !tokenUrlOrRefreshToken ) {
  33. /**
  34. * A `tokenUrl` must be provided as the first constructor argument.
  35. *
  36. * @error token-missing-token-url
  37. */
  38. throw new CKEditorError(
  39. 'token-missing-token-url',
  40. this
  41. );
  42. }
  43. if ( options.initValue ) {
  44. this._validateTokenValue( options.initValue );
  45. }
  46. /**
  47. * Value of the token.
  48. * The value of the token is null if `initValue` is not provided or `init` method was not called.
  49. * `create` method creates token with initialized value from url.
  50. *
  51. * @name value
  52. * @member {String} #value
  53. * @observable
  54. * @readonly
  55. */
  56. this.set( 'value', options.initValue );
  57. /**
  58. * Base refreshing function.
  59. *
  60. * @private
  61. * @member {String|Function} #_refresh
  62. */
  63. if ( typeof tokenUrlOrRefreshToken === 'function' ) {
  64. this._refresh = tokenUrlOrRefreshToken;
  65. } else {
  66. this._refresh = () => defaultRefreshToken( tokenUrlOrRefreshToken );
  67. }
  68. /**
  69. * @type {Object}
  70. * @private
  71. */
  72. this._options = Object.assign( {}, DEFAULT_OPTIONS, options );
  73. }
  74. /**
  75. * Initializes the token.
  76. *
  77. * @returns {Promise.<module:cloud-services-core/token~Token>}
  78. */
  79. init() {
  80. return new Promise( ( resolve, reject ) => {
  81. if ( !this.value ) {
  82. this.refreshToken()
  83. .then( resolve )
  84. .catch( reject );
  85. return;
  86. }
  87. if ( this._options.autoRefresh ) {
  88. this._registerRefreshTokenTimeout();
  89. }
  90. resolve( this );
  91. } );
  92. }
  93. /**
  94. * Refresh token method. Useful in a method form as it can be override in tests.
  95. * @returns {Promise.<String>}
  96. */
  97. refreshToken() {
  98. return this._refresh()
  99. .then( value => {
  100. this._validateTokenValue( value );
  101. this.set( 'value', value );
  102. if ( this._options.autoRefresh ) {
  103. this._registerRefreshTokenTimeout();
  104. }
  105. } )
  106. .then( () => this );
  107. }
  108. /**
  109. * Destroys token instance. Stops refreshing.
  110. */
  111. destroy() {
  112. clearTimeout( this._tokenRefreshTimeout );
  113. }
  114. /**
  115. * Checks whether the provided token follows the JSON Web Tokens (JWT) format.
  116. *
  117. * @protected
  118. * @param {String} tokenValue The token to validate.
  119. */
  120. _validateTokenValue( tokenValue ) {
  121. // The token must be a string.
  122. const isString = typeof tokenValue === 'string';
  123. // The token must be a plain string without quotes ("").
  124. const isPlainString = !/^".*"$/.test( tokenValue );
  125. // JWT token contains 3 parts: header, payload, and signature.
  126. // Each part is separated by a dot.
  127. const isJWTFormat = isString && tokenValue.split( '.' ).length === 3;
  128. if ( !( isPlainString && isJWTFormat ) ) {
  129. /**
  130. * The provided token must follow the [JSON Web Tokens](https://jwt.io/introduction/) format.
  131. *
  132. * @error token-not-in-jwt-format
  133. */
  134. throw new CKEditorError( 'token-not-in-jwt-format', this );
  135. }
  136. }
  137. /**
  138. * Registers a refresh token timeout for the time taken from token.
  139. *
  140. * @protected
  141. */
  142. _registerRefreshTokenTimeout() {
  143. const tokenRefreshTimeoutTime = this._getTokenRefreshTimeoutTime();
  144. clearTimeout( this._tokenRefreshTimeout );
  145. this._tokenRefreshTimeout = setTimeout( () => {
  146. this.refreshToken();
  147. }, tokenRefreshTimeoutTime );
  148. }
  149. /**
  150. * Returns token refresh timeout time calculated from expire time in the token payload.
  151. *
  152. * If the token parse fails or the token payload doesn't contain, the default DEFAULT_TOKEN_REFRESH_TIMEOUT_TIME is returned.
  153. *
  154. * @protected
  155. * @returns {Number}
  156. */
  157. _getTokenRefreshTimeoutTime() {
  158. try {
  159. const [ , binaryTokenPayload ] = this.value.split( '.' );
  160. const { exp: tokenExpireTime } = JSON.parse( atob( binaryTokenPayload ) );
  161. if ( !tokenExpireTime ) {
  162. return DEFAULT_TOKEN_REFRESH_TIMEOUT_TIME;
  163. }
  164. const tokenRefreshTimeoutTime = Math.floor( ( ( tokenExpireTime * 1000 ) - Date.now() ) / 2 );
  165. return tokenRefreshTimeoutTime;
  166. } catch ( err ) {
  167. return DEFAULT_TOKEN_REFRESH_TIMEOUT_TIME;
  168. }
  169. }
  170. /**
  171. * Creates a initialized {@link module:cloud-services-core/token~Token} instance.
  172. *
  173. * @param {String|Function} tokenUrlOrRefreshToken Endpoint address to download the token or a callback that provides the token. If the
  174. * value is a function it has to match the {@link module:cloud-services-core/token~refreshToken} interface.
  175. * @param {Object} options
  176. * @param {String} [options.initValue] Initial value of the token.
  177. * @param {Boolean} [options.autoRefresh=true] Specifies whether to start the refresh automatically.
  178. * @returns {Promise.<module:cloud-services-core/token~Token>}
  179. */
  180. static create( tokenUrlOrRefreshToken, options = DEFAULT_OPTIONS ) {
  181. const token = new Token( tokenUrlOrRefreshToken, options );
  182. return token.init();
  183. }
  184. }
  185. mix( Token, ObservableMixin );
  186. /**
  187. * This function is called in a defined interval by the {@link ~Token} class. It also can be invoked manually.
  188. * It should return a promise, which resolves with the new token value.
  189. * If any error occurs it should return a rejected promise with an error message.
  190. *
  191. * @function refreshToken
  192. * @returns {Promise.<String>}
  193. */
  194. /**
  195. * @private
  196. * @param {String} tokenUrl
  197. */
  198. function defaultRefreshToken( tokenUrl ) {
  199. return new Promise( ( resolve, reject ) => {
  200. const xhr = new XMLHttpRequest();
  201. xhr.open( 'GET', tokenUrl );
  202. xhr.addEventListener( 'load', () => {
  203. const statusCode = xhr.status;
  204. const xhrResponse = xhr.response;
  205. if ( statusCode < 200 || statusCode > 299 ) {
  206. /**
  207. * Cannot download new token from the provided url.
  208. *
  209. * @error token-cannot-download-new-token
  210. */
  211. return reject(
  212. new CKEditorError( 'token-cannot-download-new-token', null )
  213. );
  214. }
  215. return resolve( xhrResponse );
  216. } );
  217. xhr.addEventListener( 'error', () => reject( new Error( 'Network Error' ) ) );
  218. xhr.addEventListener( 'abort', () => reject( new Error( 'Abort' ) ) );
  219. xhr.send();
  220. } );
  221. }
  222. export default Token;