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

fix(mediaviewer): Refresh token if token expired #1087

Merged
merged 6 commits into from
Oct 29, 2019
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
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 = '';

mxiao6 marked this conversation as resolved.
Show resolved Hide resolved
/** @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.'));
mxiao6 marked this conversation as resolved.
Show resolved Hide resolved
}
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);
mxiao6 marked this conversation as resolved.
Show resolved Hide resolved
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.',
mxiao6 marked this conversation as resolved.
Show resolved Hide resolved
);
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 } : {};
mxiao6 marked this conversation as resolved.
Show resolved Hide resolved
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