Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Token should retry after refresh failure to avoid eventual client disconnection from the editing session #16890

Merged
merged 5 commits into from
Aug 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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