Skip to content

Commit

Permalink
fix(mediaviewer): Refresh token if token expired (#1087)
Browse files Browse the repository at this point in the history
* chore(mediaviewer): refresh token if token expired

* chore(mediaviewer): add test

* fix(mediaviewer): address comments

* fix(mediaviewer): address comments

* fix(mediaviewer): address comments
  • Loading branch information
Mingze authored and mergify[bot] committed Oct 29, 2019
1 parent a5a2b45 commit a69c0aa
Show file tree
Hide file tree
Showing 6 changed files with 195 additions and 9 deletions.
19 changes: 16 additions & 3 deletions src/lib/Preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,6 @@ class Preview extends EventEmitter {
/** @property {Object} - Map of disabled viewer names */
disabledViewers = {};

/** @property {string} - Access token */
token = '';

/** @property {Object} - Current viewer instance */
viewer;

Expand Down Expand Up @@ -1002,6 +999,7 @@ class Preview extends EventEmitter {
location: this.location,
cache: this.cache,
ui: this.ui,
refreshToken: this.refreshToken,
});
}

Expand Down Expand Up @@ -1882,6 +1880,21 @@ class Preview extends EventEmitter {
const fileId = typeof fileIdOrFile === 'string' ? fileIdOrFile : fileIdOrFile.id;
return getProp(this.previewOptions, `fileOptions.${fileId}.${optionName}`);
}

/**
* Refresh the access token
*
* @private
* @return {Promise<string|Error>}
*/
refreshToken = () => {
if (typeof this.previewOptions.token !== 'function') {
return Promise.reject(new Error('Token is not a function and cannot be refreshed.'));
}
return getTokens(this.file.id, this.previewOptions.token).then(
tokenOrTokenMap => tokenOrTokenMap[this.file.id],
);
};
}

global.Box = global.Box || {};
Expand Down
22 changes: 21 additions & 1 deletion src/lib/__tests__/Preview-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ describe('lib/Preview', () => {
expect(preview.file).to.deep.equal({});
expect(preview.options).to.deep.equal({});
expect(preview.disabledViewers).to.deep.equal({ Office: 1 });
expect(preview.token).to.equal('');
expect(preview.loaders).to.equal(loaders);
expect(preview.location.hostname).to.equal('localhost');
});
Expand Down Expand Up @@ -2837,5 +2836,26 @@ describe('lib/Preview', () => {
expect(preview.getFileOption('123', 'fileVersionId')).to.equal(undefined);
});
});

describe('refreshToken()', () => {
it('should return a new token if the previewOptions.token is a function', done => {
preview.file = {
id: 'file_123',
};
preview.previewOptions.token = id => Promise.resolve({ [id]: 'new_token' });
preview.refreshToken().then(token => {
expect(token).to.equal('new_token');
done();
});
});

it('should reject if previewOptions.token is not a function', done => {
preview.previewOptions.token = 'token';
preview.refreshToken().catch(error => {
expect(error.message).to.equal('Token is not a function and cannot be refreshed.');
done();
});
});
});
});
/* eslint-enable no-unused-expressions */
1 change: 1 addition & 0 deletions src/lib/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const ERROR_CODE = {
PREFETCH_FILE: 'error_prefetch_file',
RATE_LIMIT: 'error_rate_limit',
SHAKA: 'error_shaka',
TOKEN_NOT_VALID: 'error_token_function_not_valid',
UNSUPPORTED_FILE_TYPE: 'error_unsupported_file_type',
VIEWER_LOAD_TIMEOUT: 'error_viewer_load_timeout',
};
Expand Down
33 changes: 33 additions & 0 deletions src/lib/viewers/media/DashViewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class DashViewer extends VideoBaseViewer {
this.loadeddataHandler = this.loadeddataHandler.bind(this);
this.requestFilter = this.requestFilter.bind(this);
this.shakaErrorHandler = this.shakaErrorHandler.bind(this);
this.restartPlayback = this.restartPlayback.bind(this);
}

/**
Expand Down Expand Up @@ -477,6 +478,32 @@ class DashViewer extends VideoBaseViewer {
this.hideLoadingIcon();
}

/**
* Determain whether is an expired token error
*
* @private
* @param {Object} details - error details
* @return {bool}
*/
isExpiredTokenError({ details }) {
// unauthorized error may be caused by token expired
return details.code === shaka.util.Error.Code.BAD_HTTP_STATUS && details.data[1] === 401;
}

/**
* Restart playback using new token
*
* @private
* @param {string} newToken - new token
* @return {void}
*/
restartPlayback(newToken) {
this.options.token = newToken;
if (this.player.retryStreaming()) {
this.retryTokenCount = 0;
}
}

/**
* Handles errors thrown by shaka player. See https://shaka-player-demo.appspot.com/docs/api/shaka.util.Error.html
*
Expand All @@ -491,20 +518,26 @@ class DashViewer extends VideoBaseViewer {
__('error_refresh'),
{
code: normalizedShakaError.code,
data: normalizedShakaError.data,
severity: normalizedShakaError.severity,
},
`Shaka error. Code = ${normalizedShakaError.code}, Category = ${
normalizedShakaError.category
}, Severity = ${normalizedShakaError.severity}, Data = ${normalizedShakaError.data.toString()}`,
);

if (this.handleExpiredTokenError(error)) {
return;
}

if (normalizedShakaError.severity > SHAKA_CODE_ERROR_RECOVERABLE) {
// Anything greater than a recoverable error should be critical
if (normalizedShakaError.code === shaka.util.Error.Code.HTTP_ERROR) {
const downloadURL = normalizedShakaError.data[0];
this.handleDownloadError(error, downloadURL);
return;
}

// critical error
this.triggerError(error);
}
Expand Down
90 changes: 88 additions & 2 deletions src/lib/viewers/media/MediaBaseViewer.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import debounce from 'lodash/debounce';
import isEmpty from 'lodash/isEmpty';
import BaseViewer from '../BaseViewer';
import Browser from '../../Browser';
import MediaControls from './MediaControls';
Expand All @@ -21,6 +22,8 @@ const INITIAL_TIME_IN_SECONDS = 0;
const ONE_MINUTE_IN_SECONDS = 60;
const ONE_HOUR_IN_SECONDS = 60 * ONE_MINUTE_IN_SECONDS;
const PLAY_PROMISE_NOT_SUPPORTED = 'play_promise_not_supported';
const MEDIA_TOKEN_EXPIRE_ERROR = 'PIPELINE_ERROR_READ';
const MAX_RETRY_TOKEN = 3; // number of times to retry refreshing token for unauthorized error

class MediaBaseViewer extends BaseViewer {
/** @property {Object} - Keeps track of the different media metrics */
Expand All @@ -33,6 +36,9 @@ class MediaBaseViewer extends BaseViewer {
[MEDIA_METRIC.watchLength]: 0,
};

/** @property {number} - Number of times refreshing token has been retried for unauthorized error */
retryTokenCount = 0;

/**
* @inheritdoc
*/
Expand Down Expand Up @@ -61,6 +67,7 @@ class MediaBaseViewer extends BaseViewer {
this.toggleMute = this.toggleMute.bind(this);
this.togglePlay = this.togglePlay.bind(this);
this.updateVolumeIcon = this.updateVolumeIcon.bind(this);
this.restartPlayback = this.restartPlayback.bind(this);

window.addEventListener('beforeunload', this.processMetrics);
}
Expand Down Expand Up @@ -229,6 +236,14 @@ class MediaBaseViewer extends BaseViewer {
return;
}

// If it's already loaded, this handler should be triggered by refreshing token,
// so we want to continue playing from the previous time, and don't need to load UI again.
if (this.loaded) {
this.play(this.currentTime);
this.retryTokenCount = 0;
return;
}

this.loadUI();

if (this.isAutoplayEnabled()) {
Expand Down Expand Up @@ -260,6 +275,73 @@ class MediaBaseViewer extends BaseViewer {
this.wrapperEl.classList.add(CLASS_IS_VISIBLE);
}

/**
* Determain whether is an expired token error
*
* @protected
* @param {Object} details - error details
* @return {bool}
*/
isExpiredTokenError({ details }) {
return (
!isEmpty(details) &&
details.error_code === MediaError.MEDIA_ERR_NETWORK &&
details.error_message.includes(MEDIA_TOKEN_EXPIRE_ERROR)
);
}

/**
* Restart playback using new token
*
* @protected
* @param {string} newToken - new token
* @return {void}
*/
restartPlayback(newToken) {
const { currentTime } = this.mediaEl;
this.currentTime = currentTime;
this.options.token = newToken;
this.mediaUrl = this.createContentUrlWithAuthParams(this.options.representation.content.url_template);
this.mediaEl.src = this.mediaUrl;
}

/**
* Handle expired token error
*
* @protected
* @param {PreviewError} error
* @return {boolean} True if it is a token error and is handled
*/
handleExpiredTokenError(error) {
if (this.isExpiredTokenError(error)) {
if (this.retryTokenCount >= MAX_RETRY_TOKEN) {
const tokenError = new PreviewError(
ERROR_CODE.TOKEN_NOT_VALID,
null,
{ silent: true },
'Reach refreshing token limit for unauthorized error.',
);
this.triggerError(tokenError);
} else {
this.options
.refreshToken()
.then(this.restartPlayback)
.catch(e => {
const tokenError = new PreviewError(
ERROR_CODE.TOKEN_NOT_VALID,
null,
{ silent: true },
e.message,
);
this.triggerError(tokenError);
});
this.retryTokenCount += 1;
}
return true;
}
return false;
}

/**
* Handles media element loading errors.
*
Expand All @@ -273,10 +355,14 @@ class MediaBaseViewer extends BaseViewer {
console.error(err);

const errorCode = getProp(err, 'target.error.code');
const errorDetails = errorCode ? { error_code: errorCode } : {};

const errorMessage = getProp(err, 'target.error.message');
const errorDetails = errorCode ? { error_code: errorCode, error_message: errorMessage } : {};
const error = new PreviewError(ERROR_CODE.LOAD_MEDIA, __('error_refresh'), errorDetails);

if (this.handleExpiredTokenError(error)) {
return;
}

if (!this.isLoaded()) {
this.handleDownloadError(error, this.mediaUrl);
} else {
Expand Down
39 changes: 36 additions & 3 deletions src/lib/viewers/media/__tests__/MediaBaseViewer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import MediaBaseViewer from '../MediaBaseViewer';
import BaseViewer from '../../BaseViewer';
import Timer from '../../../Timer';
import { CLASS_ELEM_KEYBOARD_FOCUS } from '../../../constants';
import { VIEWER_EVENT } from '../../../events';
import { ERROR_CODE, VIEWER_EVENT } from '../../../events';
import PreviewError from '../../../PreviewError';

const MAX_RETRY_TOKEN = 3; // number of times to retry refreshing token for unauthorized error

let media;
let stubs;
Expand Down Expand Up @@ -185,22 +188,52 @@ describe('lib/viewers/media/MediaBaseViewer', () => {
});
});

describe('handleExpiredTokenError()', () => {
it('should not trigger error if is not an ExpiredTokenError', () => {
sandbox.stub(media, 'isExpiredTokenError').returns(false);
sandbox.stub(media, 'triggerError');
const error = new PreviewError(ERROR_CODE.LOAD_MEDIA);
media.handleExpiredTokenError(error);
expect(media.triggerError).to.not.be.called;
});

it('should trigger error if retry token count reaches max retry limit', () => {
media.retryTokenCount = MAX_RETRY_TOKEN + 1;
sandbox.stub(media, 'isExpiredTokenError').returns(true);
sandbox.stub(media, 'triggerError');
const error = new PreviewError(ERROR_CODE.LOAD_MEDIA);
media.handleExpiredTokenError(error);
expect(media.triggerError).to.be.calledWith(sinon.match.has('code', ERROR_CODE.TOKEN_NOT_VALID));
});

it('should call refreshToken if retry token count did not reach max retry limit', () => {
media.retryTokenCount = 0;
sandbox.stub(media, 'isExpiredTokenError').returns(true);
media.options.refreshToken = sandbox.stub().returns(Promise.resolve());
const error = new PreviewError(ERROR_CODE.LOAD_MEDIA);
media.handleExpiredTokenError(error);

expect(media.options.refreshToken).to.be.called;
expect(media.retryTokenCount).to.equal(1);
});
});

describe('errorHandler()', () => {
it('should handle download error if the viewer was not yet loaded', () => {
const err = new Error();
media.mediaUrl = 'foo';
sandbox.stub(media, 'isLoaded').returns(false);
sandbox.stub(media, 'handleDownloadError');
const err = new Error();

media.errorHandler(err);

expect(media.handleDownloadError).to.be.calledWith(sinon.match.has('code'), 'foo');
});

it('should trigger an error if Preview is already loaded', () => {
const err = new Error();
sandbox.stub(media, 'isLoaded').returns(true);
sandbox.stub(media, 'triggerError');
const err = new Error();

media.errorHandler(err);

Expand Down

0 comments on commit a69c0aa

Please sign in to comment.