diff --git a/packages/ckeditor-cloud-services-core/src/token/token.js b/packages/ckeditor-cloud-services-core/src/token/token.js index e4f57c3b87a..b1ca102c0a6 100644 --- a/packages/ckeditor-cloud-services-core/src/token/token.js +++ b/packages/ckeditor-cloud-services-core/src/token/token.js @@ -7,13 +7,14 @@ * @module cloud-services-core/token */ -/* globals XMLHttpRequest, setInterval, clearInterval */ +/* globals XMLHttpRequest, setTimeout, clearTimeout, atob */ import mix from '@ckeditor/ckeditor5-utils/src/mix'; import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin'; import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; -const DEFAULT_OPTIONS = { refreshInterval: 3600000, autoRefresh: true }; +const DEFAULT_OPTIONS = { autoRefresh: true }; +const DEFAULT_TOKEN_REFRESH_TIMEOUT_TIME = 3600000; /** * Class representing the token used for communication with CKEditor Cloud Services. @@ -30,7 +31,6 @@ class Token { * value is a function it has to match the {@link module:cloud-services-core/token~refreshToken} interface. * @param {Object} options * @param {String} [options.initValue] Initial value of the token. - * @param {Number} [options.refreshInterval=3600000] Delay between refreshes. Default 1 hour. * @param {Boolean} [options.autoRefresh=true] Specifies whether to start the refresh automatically. */ constructor( tokenUrlOrRefreshToken, options = DEFAULT_OPTIONS ) { @@ -46,6 +46,10 @@ class Token { ); } + if ( options.initValue ) { + this._validateTokenValue( options.initValue ); + } + /** * Value of the token. * The value of the token is null if `initValue` is not provided or `init` method was not called. @@ -84,10 +88,6 @@ class Token { */ init() { return new Promise( ( resolve, reject ) => { - if ( this._options.autoRefresh ) { - this._startRefreshing(); - } - if ( !this.value ) { this.refreshToken() .then( resolve ) @@ -96,6 +96,10 @@ class Token { return; } + if ( this._options.autoRefresh ) { + this._registerRefreshTokenTimeout(); + } + resolve( this ); } ); } @@ -106,7 +110,14 @@ class Token { */ refreshToken() { return this._refresh() - .then( value => this.set( 'value', value ) ) + .then( value => { + this._validateTokenValue( value ); + this.set( 'value', value ); + + if ( this._options.autoRefresh ) { + this._registerRefreshTokenTimeout(); + } + } ) .then( () => this ); } @@ -114,25 +125,69 @@ class Token { * Destroys token instance. Stops refreshing. */ destroy() { - this._stopRefreshing(); + clearTimeout( this._tokenRefreshTimeout ); } /** - * Starts value refreshing every `refreshInterval` time. + * Checks whether the provided token follows the JSON Web Tokens (JWT) format. * * @protected + * @param {String} tokenValue The token to validate. */ - _startRefreshing() { - this._refreshInterval = setInterval( () => this.refreshToken(), this._options.refreshInterval ); + _validateTokenValue( tokenValue ) { + // The token must be a string. + const isString = typeof tokenValue === 'string'; + + // The token must be a plain string without quotes (""). + const isPlainString = !/^".*"$/.test( tokenValue ); + + // JWT token contains 3 parts: header, payload, and signature. + // Each part is separated by a dot. + const isJWTFormat = isString && tokenValue.split( '.' ).length === 3; + + if ( !( isPlainString && isJWTFormat ) ) { + /** + * The provided token must follow the [JSON Web Tokens](https://jwt.io/introduction/) format. + * + * @error token-not-in-jwt-format + */ + throw new CKEditorError( 'token-not-in-jwt-format', this ); + } } /** - * Stops value refreshing. + * Registers a refresh token timeout for the time taken from token. * * @protected */ - _stopRefreshing() { - clearInterval( this._refreshInterval ); + _registerRefreshTokenTimeout() { + const tokenRefreshTimeoutTime = this._getTokenRefreshTimeoutTime(); + + clearTimeout( this._tokenRefreshTimeout ); + + this._tokenRefreshTimeout = setTimeout( () => { + this.refreshToken(); + }, tokenRefreshTimeoutTime ); + } + + /** + * Returns token refresh timeout time calculated from expire time in the token payload. + * + * If the token parse fails, the default DEFAULT_TOKEN_REFRESH_TIMEOUT_TIME is returned. + * + * @protected + * @returns {Number} + */ + _getTokenRefreshTimeoutTime() { + try { + const [ , binaryTokenPayload ] = this.value.split( '.' ); + const { exp: tokenExpireTime } = JSON.parse( atob( binaryTokenPayload ) ); + const tokenRefreshTimeoutTime = Math.floor( ( tokenExpireTime - Date.now() ) / 2 ); + + return tokenRefreshTimeoutTime; + } catch ( err ) { + return DEFAULT_TOKEN_REFRESH_TIMEOUT_TIME; + } } /** @@ -142,7 +197,6 @@ class Token { * value is a function it has to match the {@link module:cloud-services-core/token~refreshToken} interface. * @param {Object} options * @param {String} [options.initValue] Initial value of the token. - * @param {Number} [options.refreshInterval=3600000] Delay between refreshes. Default 1 hour. * @param {Boolean} [options.autoRefresh=true] Specifies whether to start the refresh automatically. * @returns {Promise.} */ diff --git a/packages/ckeditor-cloud-services-core/tests/token/token.js b/packages/ckeditor-cloud-services-core/tests/token/token.js index f393ad9a878..22b460a9d38 100644 --- a/packages/ckeditor-cloud-services-core/tests/token/token.js +++ b/packages/ckeditor-cloud-services-core/tests/token/token.js @@ -26,31 +26,56 @@ describe( 'Token', () => { } ); describe( 'constructor()', () => { - it( 'should throw error when no tokenUrl provided', () => { + it( 'should throw an error when no tokenUrl provided', () => { expect( () => new Token() ).to.throw( CKEditorError, 'token-missing-token-url' ); } ); - it( 'should set a init token value', () => { - const token = new Token( 'http://token-endpoint', { initValue: 'initValue', autoRefresh: false } ); + it( 'should throw an error if the token passed in options is not a string', () => { + expect( () => new Token( 'http://token-endpoint', { initValue: 123456 } ) ).to.throw( + CKEditorError, + 'token-not-in-jwt-format' + ); + } ); + + it( 'should throw an error if the token passed in options is wrapped in additional quotes', () => { + const tokenInitValue = getTestTokenValue(); + + expect( () => new Token( 'http://token-endpoint', { initValue: `"${ tokenInitValue }"` } ) ).to.throw( + CKEditorError, + 'token-not-in-jwt-format' + ); + } ); + + it( 'should throw an error if the token passed in options is not a valid JWT token', () => { + expect( () => new Token( 'http://token-endpoint', { initValue: 'token' } ) ).to.throw( + CKEditorError, + 'token-not-in-jwt-format' + ); + } ); - expect( token.value ).to.equal( 'initValue' ); + it( 'should set token value if the token passed in options is valid', () => { + const tokenInitValue = getTestTokenValue(); + const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue } ); + + expect( token.value ).to.equal( tokenInitValue ); } ); it( 'should fire `change:value` event if the value of the token has changed', done => { + const tokenValue = getTestTokenValue(); const token = new Token( 'http://token-endpoint', { autoRefresh: false } ); token.on( 'change:value', ( event, name, newValue ) => { - expect( newValue ).to.equal( 'token-value' ); + expect( newValue ).to.equal( tokenValue ); done(); } ); token.init(); - requests[ 0 ].respond( 200, '', 'token-value' ); + requests[ 0 ].respond( 200, '', tokenValue ); } ); it( 'should accept the callback in the constructor', () => { @@ -62,98 +87,183 @@ describe( 'Token', () => { } ); describe( 'init()', () => { - it( 'should get a token value from endpoint', done => { + it( 'should get a token value from the endpoint', done => { + const tokenValue = getTestTokenValue(); const token = new Token( 'http://token-endpoint', { autoRefresh: false } ); token.init() .then( () => { - expect( token.value ).to.equal( 'token-value' ); + expect( token.value ).to.equal( tokenValue ); done(); } ); - requests[ 0 ].respond( 200, '', 'token-value' ); + requests[ 0 ].respond( 200, '', tokenValue ); } ); it( 'should get a token from the refreshToken function when is provided', () => { - const token = new Token( () => Promise.resolve( 'token-value' ), { autoRefresh: false } ); + const tokenValue = getTestTokenValue(); + const token = new Token( () => Promise.resolve( tokenValue ), { autoRefresh: false } ); return token.init() .then( () => { - expect( token.value ).to.equal( 'token-value' ); + expect( token.value ).to.equal( tokenValue ); } ); } ); - it( 'should start token refresh every 1 hour', done => { - const clock = sinon.useFakeTimers( { toFake: [ 'setInterval' ] } ); + it( 'should not refresh token if autoRefresh is disabled in options', async () => { + const clock = sinon.useFakeTimers( { toFake: [ 'setTimeout' ] } ); + const tokenInitValue = getTestTokenValue(); - const token = new Token( 'http://token-endpoint', { initValue: 'initValue' } ); + const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue, autoRefresh: false } ); - token.init() - .then( () => { - clock.tick( 3600000 ); - clock.tick( 3600000 ); - clock.tick( 3600000 ); - clock.tick( 3600000 ); - clock.tick( 3600000 ); + await token.init(); - expect( requests.length ).to.equal( 5 ); + await clock.tickAsync( 3600000 ); - clock.restore(); + expect( requests ).to.be.empty; - done(); - } ); + clock.restore(); + } ); + + it( 'should refresh token with the time specified in token `exp` payload property', async () => { + const clock = sinon.useFakeTimers( { toFake: [ 'setTimeout' ] } ); + const tokenInitValue = getTestTokenValue(); + + const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue } ); + + await token.init(); + + await clock.tickAsync( 1800000 ); + requests[ 0 ].respond( 200, '', getTestTokenValue( 150000 ) ); + + await clock.tickAsync( 75000 ); + requests[ 1 ].respond( 200, '', getTestTokenValue( 10000 ) ); + + await clock.tickAsync( 5000 ); + requests[ 2 ].respond( 200, '', getTestTokenValue( 2000 ) ); + + await clock.tickAsync( 1000 ); + requests[ 3 ].respond( 200, '', getTestTokenValue( 300 ) ); + + await clock.tickAsync( 150 ); + requests[ 4 ].respond( 200, '', getTestTokenValue( 300 ) ); + + expect( requests.length ).to.equal( 5 ); + + clock.restore(); + } ); + + it( 'should refresh the token with the default time if getting token expiration time failed', async () => { + const clock = sinon.useFakeTimers( { toFake: [ 'setTimeout' ] } ); + const tokenValue = 'header.test.signature'; + + const token = new Token( 'http://token-endpoint', { initValue: tokenValue } ); + + await token.init(); + + await clock.tickAsync( 3600000 ); + requests[ 0 ].respond( 200, '', tokenValue ); + + await clock.tickAsync( 3600000 ); + requests[ 1 ].respond( 200, '', tokenValue ); + + expect( requests.length ).to.equal( 2 ); + + clock.restore(); } ); } ); describe( 'destroy', () => { - it( 'should stop refreshing the token', () => { - const clock = sinon.useFakeTimers( { toFake: [ 'setInterval', 'clearInterval' ] } ); - const token = new Token( 'http://token-endpoint', { initValue: 'initValue' } ); + it( 'should stop refreshing the token', async () => { + const clock = sinon.useFakeTimers( { toFake: [ 'setTimeout', 'clearTimeout' ] } ); + const tokenInitValue = getTestTokenValue(); - return token.init() - .then( () => { - clock.tick( 3600000 ); - clock.tick( 3600000 ); + const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue } ); - expect( requests.length ).to.equal( 2 ); + await token.init(); - token.destroy(); + await clock.tickAsync( 1800000 ); + requests[ 0 ].respond( 200, '', getTestTokenValue( 150000 ) ); + await clock.tickAsync( 100 ); - clock.tick( 3600000 ); - clock.tick( 3600000 ); - clock.tick( 3600000 ); + await clock.tickAsync( 75000 ); + requests[ 1 ].respond( 200, '', getTestTokenValue( 10000 ) ); + await clock.tickAsync( 100 ); - expect( requests.length ).to.equal( 2 ); - } ); + token.destroy(); + + await clock.tickAsync( 3600000 ); + await clock.tickAsync( 3600000 ); + await clock.tickAsync( 3600000 ); + + expect( requests.length ).to.equal( 2 ); + + clock.restore(); } ); } ); describe( 'refreshToken()', () => { it( 'should get a token from the specified address', done => { - const token = new Token( 'http://token-endpoint', { initValue: 'initValue', autoRefresh: false } ); + const tokenValue = getTestTokenValue(); + const token = new Token( 'http://token-endpoint', { autoRefresh: false } ); token.refreshToken() .then( newToken => { - expect( newToken.value ).to.equal( 'token-value' ); + expect( newToken.value ).to.equal( tokenValue ); done(); } ); - requests[ 0 ].respond( 200, '', 'token-value' ); + requests[ 0 ].respond( 200, '', tokenValue ); + } ); + + it( 'should throw an error if the returned token is wrapped in additional quotes', done => { + const tokenValue = getTestTokenValue(); + const token = new Token( 'http://token-endpoint', { autoRefresh: false } ); + + token.refreshToken() + .then( () => { + done( new Error( 'Promise should be rejected' ) ); + } ) + .catch( error => { + expect( error.constructor ).to.equal( CKEditorError ); + expect( error ).to.match( /token-not-in-jwt-format/ ); + done(); + } ); + + requests[ 0 ].respond( 200, '', `"${ tokenValue }"` ); + } ); + + it( 'should throw an error if the returned token is not a valid JWT token', done => { + const token = new Token( 'http://token-endpoint', { autoRefresh: false } ); + + token.refreshToken() + .then( () => { + done( new Error( 'Promise should be rejected' ) ); + } ) + .catch( error => { + expect( error.constructor ).to.equal( CKEditorError ); + expect( error ).to.match( /token-not-in-jwt-format/ ); + done(); + } ); + + requests[ 0 ].respond( 200, '', 'token' ); } ); it( 'should get a token from the specified callback function', () => { - const token = new Token( () => Promise.resolve( 'token-value' ), { initValue: 'initValue', autoRefresh: false } ); + const tokenValue = getTestTokenValue(); + const token = new Token( () => Promise.resolve( tokenValue ), { autoRefresh: false } ); return token.refreshToken() .then( newToken => { - expect( newToken.value ).to.equal( 'token-value' ); + expect( newToken.value ).to.equal( tokenValue ); } ); } ); - it( 'should throw an error when cannot download new token', () => { - const token = new Token( 'http://token-endpoint', { initValue: 'initValue', autoRefresh: false } ); + it( 'should throw an error when cannot download a new token', () => { + const tokenInitValue = getTestTokenValue(); + const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue, autoRefresh: false } ); const promise = token._refresh(); requests[ 0 ].respond( 401 ); @@ -167,7 +277,8 @@ describe( 'Token', () => { } ); it( 'should throw an error when the response is aborted', () => { - const token = new Token( 'http://token-endpoint', { initValue: 'initValue', autoRefresh: false } ); + const tokenInitValue = getTestTokenValue(); + const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue, autoRefresh: false } ); const promise = token._refresh(); requests[ 0 ].abort(); @@ -180,7 +291,8 @@ describe( 'Token', () => { } ); it( 'should throw an error when network error occurs', () => { - const token = new Token( 'http://token-endpoint', { initValue: 'initValue', autoRefresh: false } ); + const tokenInitValue = getTestTokenValue(); + const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue, autoRefresh: false } ); const promise = token._refresh(); requests[ 0 ].error(); @@ -192,8 +304,9 @@ describe( 'Token', () => { } ); } ); - it( 'should throw an error when the callback throws error', () => { - const token = new Token( () => Promise.reject( 'Custom error occurred' ), { initValue: 'initValue', autoRefresh: false } ); + it( 'should throw an error when the callback throws an error', () => { + const tokenInitValue = getTestTokenValue(); + const token = new Token( () => Promise.reject( 'Custom error occurred' ), { initValue: tokenInitValue, autoRefresh: false } ); token.refreshToken() .catch( error => { @@ -202,75 +315,39 @@ describe( 'Token', () => { } ); } ); - describe( '_startRefreshing()', () => { - it( 'should start refreshing', () => { - const clock = sinon.useFakeTimers( { toFake: [ 'setInterval' ] } ); - - const token = new Token( 'http://token-endpoint', { initValue: 'initValue', autoRefresh: false } ); - - token._startRefreshing(); - - clock.tick( 3600000 ); - clock.tick( 3600000 ); - clock.tick( 3600000 ); - clock.tick( 3600000 ); - clock.tick( 3600000 ); - - expect( requests.length ).to.equal( 5 ); - - clock.restore(); - } ); - } ); - - describe( '_stopRefreshing()', () => { - it( 'should stop refreshing', done => { - const clock = sinon.useFakeTimers( { toFake: [ 'setInterval', 'clearInterval' ] } ); - - const token = new Token( 'http://token-endpoint', { initValue: 'initValue' } ); - - token.init() - .then( () => { - clock.tick( 3600000 ); - clock.tick( 3600000 ); - clock.tick( 3600000 ); - - token._stopRefreshing(); - - clock.tick( 3600000 ); - clock.tick( 3600000 ); - - expect( requests.length ).to.equal( 3 ); - - clock.restore(); - - done(); - } ); - } ); - } ); - describe( 'static create()', () => { - it( 'should return a initialized token', done => { + it( 'should return an initialized token', done => { + const tokenValue = getTestTokenValue(); + Token.create( 'http://token-endpoint', { autoRefresh: false } ) .then( token => { - expect( token.value ).to.equal( 'token-value' ); + expect( token.value ).to.equal( tokenValue ); done(); } ); - requests[ 0 ].respond( 200, '', 'token-value' ); + requests[ 0 ].respond( 200, '', tokenValue ); } ); it( 'should use default options when none passed', done => { - const intervalSpy = sinon.spy( window, 'setInterval' ); + const tokenValue = getTestTokenValue(); Token.create( 'http://token-endpoint' ) - .then( () => { - expect( intervalSpy.args[ 0 ][ 1 ] ).to.equal( 3600000 ); + .then( token => { + expect( token._options ).to.eql( { autoRefresh: true } ); done(); } ); - requests[ 0 ].respond( 200, '', 'token-value' ); + requests[ 0 ].respond( 200, '', tokenValue ); } ); } ); } ); + +// Returns valid token for tests with given expiration time offset. +// +// @param {Number} [timeOffset=3600000] +// @returns {String} +function getTestTokenValue( timeOffset = 3600000 ) { + return `header.${ btoa( JSON.stringify( { exp: Date.now() + timeOffset } ) ) }.signature`; +} diff --git a/packages/ckeditor-cloud-services-core/tests/uploadgateway/fileuploader.js b/packages/ckeditor-cloud-services-core/tests/uploadgateway/fileuploader.js index 46d42aa29f3..dec4729849e 100644 --- a/packages/ckeditor-cloud-services-core/tests/uploadgateway/fileuploader.js +++ b/packages/ckeditor-cloud-services-core/tests/uploadgateway/fileuploader.js @@ -14,7 +14,8 @@ const BASE_64_FILE = 'data:image/gif;base64,R0lGODlhCQAJAPIAAGFhYZXK/1FRUf///' + '9ra2gD/AAAAAAAAACH5BAEAAAUALAAAAAAJAAkAAAMYWFqwru2xERcYJLSNNWNBVimC5wjfaTkJADs='; describe( 'FileUploader', () => { - const token = new Token( 'url', { initValue: 'token', autoRefresh: false } ); + const tokenInitValue = `header.${ btoa( JSON.stringify( { exp: Date.now() + 3600000 } ) ) }.signature`; + const token = new Token( 'url', { initValue: tokenInitValue, autoRefresh: false } ); let fileUploader; @@ -116,9 +117,12 @@ describe( 'FileUploader', () => { expect( request.url ).to.equal( API_ADDRESS ); expect( request.method ).to.equal( 'POST' ); expect( request.responseType ).to.equal( 'json' ); - expect( request.requestHeaders ).to.be.deep.equal( { Authorization: 'token' } ); + expect( request.requestHeaders ).to.be.deep.equal( { Authorization: tokenInitValue } ); done(); + } ) + .catch( err => { + console.log( err ); } ); request.respond( 200, { 'Content-Type': 'application/json' }, diff --git a/packages/ckeditor-cloud-services-core/tests/uploadgateway/uploadgateway.js b/packages/ckeditor-cloud-services-core/tests/uploadgateway/uploadgateway.js index 613dadc8015..f8308e5d8f7 100644 --- a/packages/ckeditor-cloud-services-core/tests/uploadgateway/uploadgateway.js +++ b/packages/ckeditor-cloud-services-core/tests/uploadgateway/uploadgateway.js @@ -3,13 +3,16 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ +/* eslint-env browser */ + import FileUploader from '../../src/uploadgateway/fileuploader'; import UploadGateway from '../../src/uploadgateway/uploadgateway'; import Token from '../../src/token/token'; import { expectToThrowCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'; describe( 'UploadGateway', () => { - const token = new Token( 'url', { initValue: 'token', autoRefresh: false } ); + const tokenInitValue = `header.${ btoa( JSON.stringify( { exp: Date.now() + 3600000 } ) ) }.signature`; + const token = new Token( 'url', { initValue: tokenInitValue, autoRefresh: false } ); describe( 'constructor()', () => { it( 'should throw error when no token provided', () => {