Skip to content

Commit

Permalink
Merge pull request #16890 from ckeditor/cc/6561-token-refresh-failure
Browse files Browse the repository at this point in the history
Fix (cloud-services): The `Token` refresh should retry after a failure to limit the chance of the user getting disconnected and data loss in real-time collaboration.
  • Loading branch information
oleq authored Aug 21, 2024
2 parents 01b9275 + 348d08f commit 0154d4c
Show file tree
Hide file tree
Showing 3 changed files with 208 additions and 8 deletions.
3 changes: 3 additions & 0 deletions packages/ckeditor5-cloud-services/src/cloudservicesconfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ export interface CloudServicesConfig {
* } )
* ```
*
* If the request to the token endpoint fails, the editor will call the token request function every 5 seconds in attempt
* to refresh the token.
*
* You can find more information about token endpoints in the
* {@glink @cs guides/easy-image/quick-start#create-token-endpoint Cloud Services - Quick start}
* and {@glink @cs developer-resources/security/token-endpoint Cloud Services - Token endpoint} documentation.
Expand Down
60 changes: 52 additions & 8 deletions packages/ckeditor5-cloud-services/src/token/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,17 @@

/* globals XMLHttpRequest, setTimeout, clearTimeout, atob */

import { ObservableMixin, CKEditorError } from 'ckeditor5/src/utils.js';
import { ObservableMixin, CKEditorError, logWarning } from 'ckeditor5/src/utils.js';
import type { TokenUrl } from '../cloudservicesconfig.js';

const DEFAULT_OPTIONS = { autoRefresh: true };
const DEFAULT_TOKEN_REFRESH_TIMEOUT_TIME = 3600000;
const DEFAULT_TOKEN_REFRESH_TIMEOUT_TIME = 3600000; // 1 hour
const TOKEN_FAILED_REFRESH_TIMEOUT_TIME = 5000; // 5 seconds

/**
* Class representing the token used for communication with CKEditor Cloud Services.
* Value of the token is retrieving from the specified URL and is refreshed every 1 hour by default.
* The class representing the token used for communication with CKEditor Cloud Services.
* The value of the token is retrieved from the specified URL and refreshed every 1 hour by default.
* If the token retrieval fails, the token will automatically retry in 5 seconds intervals.
*/
export default class Token extends /* #__PURE__ */ ObservableMixin() {
/**
Expand All @@ -36,8 +38,14 @@ export default class Token extends /* #__PURE__ */ ObservableMixin() {
*/
private _refresh: () => Promise<string>;

/**
* Cached token options.
*/
private _options: { initValue?: string; autoRefresh: boolean };

/**
* `setTimeout()` id for a token refresh when {@link module:cloud-services/token/token~TokenOptions auto refresh} is enabled.
*/
private _tokenRefreshTimeout?: ReturnType<typeof setTimeout>;

/**
Expand Down Expand Up @@ -99,19 +107,55 @@ export default class Token extends /* #__PURE__ */ ObservableMixin() {
}

/**
* Refresh token method. Useful in a method form as it can be override in tests.
* Refresh token method. Useful in a method form as it can be overridden in tests.
*
* This method will be invoked periodically based on the token expiry date after first call to keep the token up-to-date
* (requires {@link module:cloud-services/token/token~TokenOptions auto refresh option} to be set).
*
* If the token refresh fails, the method will retry in 5 seconds intervals until success or the token gets
* {@link #destroy destroyed}.
*/
public refreshToken(): Promise<InitializedToken> {
const autoRefresh = this._options.autoRefresh;

return this._refresh()
.then( value => {
this._validateTokenValue( value );
this.set( 'value', value );

if ( this._options.autoRefresh ) {
if ( autoRefresh ) {
this._registerRefreshTokenTimeout();
}

return this as InitializedToken;
} )
.catch( err => {
/**
* You will see this warning when the CKEditor {@link module:cloud-services/token/token~Token token} could not be refreshed.
* This may be a result of a network error, a token endpoint (server) error, or an invalid
* {@link module:cloud-services/cloudservicesconfig~CloudServicesConfig#tokenUrl token URL configuration}.
*
* If this warning repeats, please make sure that the configuration is correct and that the token
* endpoint is up and running. {@link module:cloud-services/cloudservicesconfig~CloudServicesConfig#tokenUrl Learn more}
* about token configuration.
*
* **Note:** If the token's {@link module:cloud-services/token/token~TokenOptions auto refresh option} is enabled,
* attempts to refresh will be made until success or token's
* {@link module:cloud-services/token/token~Token#destroy destruction}.
*
* @error token-refresh-failed
* @param autoRefresh Whether the token will keep auto refreshing.
*/
logWarning( 'token-refresh-failed', { autoRefresh } );

// If the refresh failed, keep trying to refresh the token. Failing to do so will eventually
// lead to the disconnection from the RTC service and the editing session (and potential data loss
// if the user keeps editing).
if ( autoRefresh ) {
this._registerRefreshTokenTimeout( TOKEN_FAILED_REFRESH_TIMEOUT_TIME );
}

throw err;
} );
}

Expand Down Expand Up @@ -151,8 +195,8 @@ export default class Token extends /* #__PURE__ */ ObservableMixin() {
/**
* Registers a refresh token timeout for the time taken from token.
*/
private _registerRefreshTokenTimeout() {
const tokenRefreshTimeoutTime = this._getTokenRefreshTimeoutTime();
private _registerRefreshTokenTimeout( timeoutTime?: number ) {
const tokenRefreshTimeoutTime = timeoutTime || this._getTokenRefreshTimeoutTime();

clearTimeout( this._tokenRefreshTimeout );

Expand Down
153 changes: 153 additions & 0 deletions packages/ckeditor5-cloud-services/tests/token/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@

import Token from '../../src/token/token.js';
import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror.js';
import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js';

describe( 'Token', () => {
let requests;

testUtils.createSinonSandbox();

beforeEach( () => {
requests = [];

Expand Down Expand Up @@ -241,6 +244,8 @@ describe( 'Token', () => {
} );

it( 'should throw an error if the returned token is wrapped in additional quotes', done => {
testUtils.sinon.stub( console, 'warn' );

const tokenValue = getTestTokenValue();
const token = new Token( 'http://token-endpoint', { autoRefresh: false } );

Expand All @@ -258,6 +263,8 @@ describe( 'Token', () => {
} );

it( 'should throw an error if the returned token is not a valid JWT token', done => {
testUtils.sinon.stub( console, 'warn' );

const token = new Token( 'http://token-endpoint', { autoRefresh: false } );

token.refreshToken()
Expand Down Expand Up @@ -335,6 +342,152 @@ describe( 'Token', () => {
expect( error ).to.equal( 'Custom error occurred' );
} );
} );

describe( 'refresh failure handling', () => {
let clock;

beforeEach( () => {
clock = sinon.useFakeTimers( {
toFake: [ 'setTimeout', 'clearTimeout' ]
} );

testUtils.sinon.stub( console, 'warn' );
} );

afterEach( () => {
clock.restore();
} );

it( 'should log a warning in the console', () => {
const tokenInitValue = getTestTokenValue();
const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue, autoRefresh: false } );
const promise = token.refreshToken();

requests[ 0 ].error();

return promise.then( () => {
throw new Error( 'Promise should fail' );
}, () => {
sinon.assert.calledWithMatch( console.warn, 'token-refresh-failed', { autoRefresh: false } );
} );
} );

it( 'should attempt to periodically refresh the token', async () => {
const tokenInitValue = getTestTokenValue();
const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue, autoRefresh: true } );
const promise = token.refreshToken();

requests[ 0 ].error();

return promise
.then( async () => {
throw new Error( 'Promise should fail' );
} )
.catch( async err => {
expect( err ).to.match( /Network Error/ );

await clock.tickAsync( '05' );
expect( requests.length ).to.equal( 2 );

requests[ 1 ].error();

await clock.tickAsync( '05' );
expect( requests.length ).to.equal( 3 );

requests[ 2 ].error();

await clock.tickAsync( '05' );
expect( requests.length ).to.equal( 4 );
} );
} );

it( 'should restore the regular refresh interval after a successfull refresh', () => {
const tokenInitValue = getTestTokenValue();
const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue, autoRefresh: true } );
const promise = token.refreshToken();

requests[ 0 ].error();

return promise
.then( async () => {
throw new Error( 'Promise should fail' );
} )
.catch( async err => {
expect( err ).to.match( /Network Error/ );

await clock.tickAsync( '05' );
expect( requests.length ).to.equal( 2 );

requests[ 1 ].respond( 200, '', getTestTokenValue( 20 ) );

await clock.tickAsync( '05' );
// Switched to 10s interval because refresh was successful.
expect( requests.length ).to.equal( 2 );

await clock.tickAsync( '05' );
expect( requests.length ).to.equal( 3 );

requests[ 2 ].respond( 200, '', getTestTokenValue( 20 ) );

await clock.tickAsync( '10' );
expect( requests.length ).to.equal( 4 );
} );
} );

it( 'should not auto-refresh after a failure if options.autoRefresh option is false', () => {
const tokenInitValue = getTestTokenValue();
const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue, autoRefresh: false } );
const promise = token.refreshToken();

requests[ 0 ].error();

return promise
.then( async () => {
throw new Error( 'Promise should fail' );
} )
.catch( async err => {
expect( err ).to.match( /Network Error/ );

await clock.tickAsync( '05' );
expect( requests.length ).to.equal( 1 );

await clock.tickAsync( '10' );
expect( requests.length ).to.equal( 1 );
} );
} );

it( 'should clear any queued refresh upon manual refreshToken() call to avoid duplicated refreshes', () => {
const tokenInitValue = getTestTokenValue();
const token = new Token( 'http://token-endpoint', { initValue: tokenInitValue, autoRefresh: true } );
const promise = token.refreshToken();

requests[ 0 ].error();

return promise
.then( async () => {
throw new Error( 'Promise should fail' );
} )
.catch( async err => {
expect( err ).to.match( /Network Error/ );

await clock.tickAsync( '05' );
expect( requests.length ).to.equal( 2 );

token.refreshToken();
token.refreshToken();
token.refreshToken();

requests[ 1 ].error();
requests[ 2 ].error();
requests[ 3 ].error();
requests[ 4 ].error();

await clock.tickAsync( '05' );

expect( requests.length ).to.equal( 6 );
} );
} );
} );
} );

describe( 'static create()', () => {
Expand Down

0 comments on commit 0154d4c

Please sign in to comment.