src/controller/authorization.js
import NPROneSDK from './../index';
import AccessToken from './../model/access-token';
import DeviceCode from './../model/device-code';
import Logger from './../util/logger';
import FetchUtil from './../util/fetch-util';
import ApiError from './../error/api-error';
/**
* Simulates a delay by wrapping a Promise around JavaScript's native `setTimeout` function.
*
* @param {number} ms The amount of time to delay for, in milliseconds
* @returns {Promise}
* @private
*/
const delay = ms => new Promise(r => setTimeout(r, ms));
/**
* Encapsulates all of the logic for communication with the [Authorization Service](https://dev.npr.org/api/#/authorization)
* in the NPR One API.
*
* Note that consumers should not be accessing this class directly but should instead use the provided pass-through
* functions in the main {@link NprOneSDK} class.
*
* @example <caption>Rudimentary example of implementing the Device Code flow</caption>
* const nprOneSDK = new NprOneSDK();
* nprOneSDK.config = { ... };
* const scopes = ['identity.readonly', 'identity.write', 'listening.readonly', 'listening.write'];
* nprOneSDK.getDeviceCode(scopes)
* .then((deviceCodeModel) => {
* // display code to user on the screen
* nprOneSDK.pollDeviceCode()
* .then(() => {
* nprOneSDK.getRecommendation();
* });
* })
* .catch(() => {
* nprOneSDK.getDeviceCode(scopes).then(...); // repeat ad infinitum until `pollDeviceCode()` resolves successfully
* // In actual use, it may be preferable to refactor this into a recursive function
* ));
*/
export default class Authorization {
/**
* Initializes the controller class with private variables needed later on.
*/
constructor() {
/** @type {null|DeviceCode} The device code model for the currently-active device code grant
* @private */
this._activeDeviceCodeModel = null;
}
/**
* Attempts to swap the existing access token for a new one using the refresh token endpoint in the OAuth proxy
*
* @param {number} [numRetries=0] The number of times this function has been tried. Will retry up to 3 times.
* @returns {Promise<AccessToken>}
* @throws {TypeError} if an OAuth proxy is not configured or no access token is set
*/
static refreshExistingAccessToken(numRetries = 0) {
if (!NPROneSDK.config.authProxyBaseUrl) {
throw new TypeError('OAuth proxy not configured. Unable to refresh the access token.');
}
if (!NPROneSDK.accessToken) {
throw new TypeError('An access token must be set in order to attempt a refresh.');
}
Logger.debug('Access token appears to have expired. Attempting to generate a fresh one.');
const url = `${NPROneSDK.config.authProxyBaseUrl}${NPROneSDK.config.refreshTokenPath}`;
const options = {
method: 'POST',
credentials: 'include',
};
return FetchUtil.nprApiFetch(url, options)
.then((json) => {
const tokenModel = new AccessToken(json);
tokenModel.validate(); // throws exception if invalid
Logger.debug('Access token refresh was successful, new token:',
tokenModel.toString());
NPROneSDK.accessToken = tokenModel.token;
return tokenModel; // never directly consumed, but useful for testing
})
.catch((err) => {
Logger.debug('Error generating a new token in refreshExistingAccessToken()');
Logger.debug(err);
if (numRetries < 2) {
Logger.debug('refreshExistingAccessToken() will make another attempt');
return delay(5000)
.then(Authorization.refreshExistingAccessToken.bind(this, numRetries + 1));
}
// rethrow
Logger.debug('refreshExistingAccessToken() has made too many attempts, aborting');
return Promise.reject(err);
});
}
/**
* Logs out the user, revoking their access token from the authorization server and removing the refresh token from
* the secure storage in the backend proxy (if a backend proxy is configured). Note that the consuming client is
* still responsible for removing the access token anywhere else it might be stored outside of this SDK (e.g. in
* localStorage or elsewhere in application memory).
*
* @returns {Promise}
* @throws {TypeError} if an OAuth proxy is not configured or no access token is currently set
*/
logout() {
if (!NPROneSDK.accessToken) {
throw new TypeError('An access token must be set in order to attempt a logout.');
}
if (!NPROneSDK.config.authProxyBaseUrl) {
throw new TypeError('OAuth proxy not configured. Unable to securely log out the user.');
}
const url = `${NPROneSDK.config.authProxyBaseUrl}${NPROneSDK.config.logoutPath}`;
const options = {
method: 'POST',
credentials: 'include',
body: `token=${NPROneSDK.accessToken}`,
headers: {
Accept: 'application/json, application/xml, text/plain, text/html, *.*',
'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
},
};
return fetch(url, options) // we cannot use FetchUtil.nprApiFetch() here because the success response has an empty body
.then((response) => {
if (response.ok) {
NPROneSDK.accessToken = '';
return true;
}
return FetchUtil.formatErrorResponse(response);
});
}
/**
* Uses the OAuth proxy to start a `device_code` grant flow. This function _just_ makes an API call that produces a
* device code/user code pair, and should be followed up with a call to {@link pollDeviceCode} in order to complete
* the process.
*
* Note that device code/user code pairs do expire after a set time, so the consuming client may need to call these
* 2 functions multiple times before the user logs in. It is a good idea to encapsulate them in a function which
* can be called recursively on errors; see the example below for details.
*
* @example
* function logInViaDeviceCode(scopes) {
* nprOneSDK.getDeviceCode(scopes)
* .then((deviceCodeModel) => {
* displayCodeToUser(deviceCodeModel); // display code to user on the screen
* nprOneSDK.pollDeviceCode()
* .then(() => {
* startPlayingAudio(); // you're now ready to call `nprOneSDK.getRecommendation()` elsewhere in your app
* }).catch(logInViaDeviceCode.bind(this, scopes)); // recursively call this function until the user logs in
* });
* }
*
* @see https://dev.npr.org/guide/services/authorization/#device_code
*
* @param {Array<string>} [scopes=[]] The scopes (as strings) that should be associated with the resulting access token
* @returns {Promise<DeviceCode>}
* @throws {TypeError} if an OAuth proxy is not configured
*/
getDeviceCode(scopes = []) {
if (!NPROneSDK.config.authProxyBaseUrl) {
throw new TypeError('OAuth proxy not configured. Unable to use the device code.');
}
const url = `${NPROneSDK.config.authProxyBaseUrl}${NPROneSDK.config.newDeviceCodePath}`;
const options = {
method: 'POST',
credentials: 'include',
body: `scope=${encodeURIComponent(scopes.join(' ')).replace('%20', '+')}`,
headers: {
Accept: 'application/json, application/xml, text/plain, text/html, *.*',
'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
},
};
return FetchUtil.nprApiFetch(url, options)
.then((json) => {
const deviceCodeModel = new DeviceCode(json);
deviceCodeModel.validate(); // throws exception if invalid
this._activeDeviceCodeModel = deviceCodeModel;
return deviceCodeModel;
});
}
/**
* Uses the OAuth proxy to poll the access token endpoint as part of a `device_code` grant flow. This endpoint will
* continue to poll until the user successfully logs in, _or_ the user goes to log in but then denies the request
* for access to their account by this client, _or_ the device code/user code pair expires, whichever comes first.
* In the first case, it will automatically set {@link NPROneSDK.accessToken} to the newly-generated access token,
* and the consuming client can proceed to play recommendations immediately; in the other 2 cases, it will return
* a Promise that rejects with a debugging message, but the next course of action would generally be to call
* {@link getDeviceCode} again and start the whole process from the top.
*
* @example
* function logInViaDeviceCode(scopes) {
* nprOneSDK.getDeviceCode(scopes)
* .then((deviceCodeModel) => {
* displayCodeToUser(deviceCodeModel); // display code to user on the screen
* nprOneSDK.pollDeviceCode()
* .then(() => {
* startPlayingAudio(); // you're now ready to call `nprOneSDK.getRecommendation()` elsewhere in your app
* }).catch(logInViaDeviceCode.bind(this, scopes)); // recursively call this function until the user logs in
* });
* }
*
* @see https://dev.npr.org/guide/services/authorization/#device_code
*
* @returns {Promise<AccessToken>}
* @throws {TypeError} if an OAuth proxy is not configured or `getDeviceCode()` was not previously called
*/
pollDeviceCode() {
Logger.debug('Starting to poll device code. Will poll until user logs in or code expires'); // eslint-disable-line max-len
if (!NPROneSDK.config.authProxyBaseUrl) {
throw new TypeError('OAuth proxy not configured. Unable to use the device code.');
}
if (!this._activeDeviceCodeModel) {
throw new TypeError('No active device code set. Please call getDeviceCode() before calling this function.'); // eslint-disable-line max-len
}
return this._pollDeviceCodeOnce();
}
/**
* Polls the device code once. If the result is an error of type `'authorization_pending'`, this will recurse,
* calling itself after a delay equal to the interval specified in the original call to {@link getDeviceCode}.
*
* @returns {Promise<AccessToken>}
* @private
*/
_pollDeviceCodeOnce() {
Logger.debug('Polling device code once');
if (this._activeDeviceCodeModel.isExpired()) {
return Promise.reject('The device code has expired. Please generate a new one before continuing.'); // eslint-disable-line max-len
}
const url = `${NPROneSDK.config.authProxyBaseUrl}${NPROneSDK.config.pollDeviceCodePath}`;
const options = {
method: 'POST',
credentials: 'include',
};
return FetchUtil.nprApiFetch(url, options)
.then((json) => {
Logger.debug('Device code poll returned successfully! An access token was returned.'); // eslint-disable-line max-len
const tokenModel = new AccessToken(json);
tokenModel.validate(); // throws exception if invalid
NPROneSDK.accessToken = tokenModel.token;
return tokenModel; // never directly consumed, but useful for testing
})
.catch((error) => {
if (error instanceof ApiError) {
if (error.statusCode === 401) {
if (error.json.type === 'authorization_pending') {
return delay(this._activeDeviceCodeModel.interval)
.then(this._pollDeviceCodeOnce.bind(this));
}
Logger.debug('The response was a 401, but not of type "authorization_pending". The user presumably denied the app access; rejecting.'); // eslint-disable-line max-len
} else {
Logger.debug('Response was not a 401. The device code has probably expired; rejecting.'); // eslint-disable-line max-len
}
} else {
Logger.debug('An unknown type of error was received. Unsure of how to respond; rejecting.'); // eslint-disable-line max-len
}
return Promise.reject(error);
});
}
}