@webex/webex-core
Version:
Plugin handling for Cisco Webex
1,174 lines (946 loc) • 38.3 kB
JavaScript
/*!
* Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file.
*/
import {set} from 'lodash';
import {assert} from '@webex/test-helper-chai';
import sinon from 'sinon';
import MockWebex from '@webex/test-helper-mock-webex';
import {Credentials, Token, grantErrors} from '@webex/webex-core';
import {inBrowser} from '@webex/common';
import FakeTimers from '@sinonjs/fake-timers';
import {skipInBrowser} from '@webex/test-helper-mocha';
import Logger from '@webex/plugin-logger';
import Metrics, {config} from '@webex/internal-plugin-metrics';
/* eslint camelcase: [0] */
// eslint-disable-next-line no-empty-function
function noop() {}
function promiseTick(count) {
let promise = Promise.resolve();
while (count > 1) {
promise = promise.then(() => promiseTick(1));
count -= 1;
}
return promise;
}
const AUTHORIZATION_STRING =
'https://api.ciscospark.com/v1/authorize?client_id=MOCK_CLIENT_ID&response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A8000&scope=spark%3Arooms_read%20spark%3Ateams_read&state=set_state_here';
describe('webex-core', () => {
describe('Credentials', () => {
let clock;
beforeEach(() => {
clock = FakeTimers.install({now: Date.now()});
});
afterEach(() => {
clock.uninstall();
});
function makeToken(webex, options) {
return new Token(options, {parent: webex});
}
describe('#calcRefreshTimeout', () => {
it('generates a number between 60-90% of expiration', () => {
const expiration = 1000;
const webex = new MockWebex();
const credentials = new Credentials(undefined, {parent: webex});
const result = credentials.calcRefreshTimeout(expiration);
assert.isTrue(result >= expiration * 0.6);
assert.isTrue(result <= expiration * 0.9);
});
});
describe('#isUnverifiedGuest', () => {
let credentials;
let webex;
beforeEach(() => {
//generate the webex instance
webex = new MockWebex();
credentials = new Credentials(undefined, {parent: webex});
});
it('should have #isUnverifiedGuest', () => {
assert.exists(credentials.isUnverifiedGuest);
});
it('should get the user status and return as a boolean', () => {
credentials.set('supertoken', 'AT');
assert.isFalse(credentials.isUnverifiedGuest);
});
it('should get guest user ', () => {
credentials.set('supertoken', 'eyJhbGciOiJSUzI1NiJ9.eyJ1c2VyX3R5cGUiOiJndWVzdCJ9');
assert.isTrue(credentials.isUnverifiedGuest);
});
it('should get login user ', () => {
credentials.set('supertoken', 'dGhpc2lzbm90YXJlYWx1c2VydG9rZW4=');
assert.isFalse(credentials.isUnverifiedGuest);
});
});
describe('#canAuthorize', () => {
it('indicates if the current state has enough information to populate an auth header, even if a token refresh or token downscope is required', () => {
const webex = new MockWebex();
webex.config.credentials.refreshCallback = inBrowser && noop;
let credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
assert.isFalse(credentials.canAuthorize);
credentials.supertoken = makeToken(webex, {
access_token: 'AT',
});
assert.isTrue(credentials.canAuthorize);
credentials.supertoken.unset('access_token');
assert.isFalse(credentials.canAuthorize);
credentials.supertoken = makeToken(webex, {
access_token: 'AT',
});
assert.isTrue(credentials.canAuthorize);
credentials.supertoken = makeToken(webex, {
access_token: 'AT',
expires: Date.now() - 10000,
});
assert.isFalse(credentials.supertoken.canAuthorize);
assert.isFalse(credentials.canRefresh);
assert.isFalse(credentials.canAuthorize);
webex.config.credentials.refreshCallback = inBrowser && noop;
credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
assert.isFalse(credentials.canAuthorize);
credentials.supertoken = makeToken(webex, {
access_token: 'AT',
refresh_token: 'RT',
});
credentials.supertoken.unset('access_token');
assert.isTrue(credentials.canAuthorize);
});
});
describe('#canRefresh', () => {
it('indicates if there is presently enough information to refresh', () => {
const webex = new MockWebex();
let credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
assert.isFalse(credentials.canRefresh);
credentials.supertoken = makeToken(
webex,
{
access_token: 'AT',
},
{parent: true}
);
assert.isFalse(credentials.canRefresh);
webex.config.credentials.refreshCallback = inBrowser && noop;
credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
assert.isFalse(credentials.canRefresh);
credentials.supertoken = makeToken(webex, {
access_token: 'AT',
refresh_token: 'RT',
});
assert.isTrue(credentials.supertoken.canRefresh);
assert.isTrue(credentials.canRefresh);
});
});
describe('#buildLoginUrl()', () => {
it('requires `state` to be an object', () => {
const webex = new MockWebex({
children: {
credentials: Credentials,
},
});
webex.trigger('change:config)');
assert.doesNotThrow(() => {
webex.credentials.buildLoginUrl();
}, /if specified, `options.state` must be an object/);
assert.doesNotThrow(() => {
webex.credentials.buildLoginUrl({});
}, /if specified, `options.state` must be an object/);
assert.throws(() => {
webex.credentials.buildLoginUrl({state: 'state'});
}, /if specified, `options.state` must be an object/);
assert.doesNotThrow(() => {
webex.credentials.buildLoginUrl({state: {}});
}, /if specified, `options.state` must be an object/);
});
it('prefers the hydra auth url, but falls back to idbroker', () => {
const webex = new MockWebex();
let credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
assert.match(credentials.buildLoginUrl({state: {}}), /idbroker/);
webex.config.credentials = {
authorizationString: AUTHORIZATION_STRING,
};
credentials = new Credentials({}, {parent: webex});
webex.trigger('change:config');
assert.match(credentials.buildLoginUrl({state: {}}), /api.ciscospark.com/);
});
skipInBrowser(it)('generates the login url', () => {
const webex = new MockWebex();
const credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
assert.equal(
credentials.buildLoginUrl({state: {page: 'front'}}),
`${
process.env.IDBROKER_BASE_URL || 'https://idbroker.webex.com'
}/idb/oauth2/v1/authorize?state=eyJwYWdlIjoiZnJvbnQifQ&client_id=fake&redirect_uri=http%3A%2F%2Fexample.com&scope=scope%3Aone&response_type=code`
);
});
skipInBrowser(it)('generates the login url with empty state param', () => {
const webex = new MockWebex();
const credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
assert.equal(
credentials.buildLoginUrl({state: {}}),
`${
process.env.IDBROKER_BASE_URL || 'https://idbroker.webex.com'
}/idb/oauth2/v1/authorize?client_id=fake&redirect_uri=http%3A%2F%2Fexample.com&scope=scope%3Aone&response_type=code`
);
});
});
describe('#buildLogoutUrl()', () => {
skipInBrowser(it)('generates the logout url', () => {
const webex = new MockWebex();
webex.config.credentials.redirect_uri = 'ru';
const credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
assert.equal(
credentials.buildLogoutUrl(),
`${
process.env.IDBROKER_BASE_URL || 'https://idbroker.webex.com'
}/idb/oauth2/v1/logout?cisService=webex&goto=ru`
);
});
skipInBrowser(it)('includes a token param if passed', () => {
const webex = new MockWebex();
webex.config.credentials.redirect_uri = 'ru';
const credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
assert.equal(
credentials.buildLogoutUrl({token: 't'}),
`${
process.env.IDBROKER_BASE_URL || 'https://idbroker.webex.com'
}/idb/oauth2/v1/logout?cisService=webex&goto=ru&token=t`
);
});
it('always fallsback to idbroker', () => {
const webex = new MockWebex();
webex.config.credentials.redirect_uri = 'ru';
const credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
assert.match(credentials.buildLogoutUrl(), /idbroker.*?goto=ru/);
});
it('allows overriding the goto url', () => {
const webex = new MockWebex();
const credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
assert.match(
credentials.buildLogoutUrl({goto: 'http://example.com/'}),
/goto=http%3A%2F%2Fexample.com%2F/
);
});
});
describe('#getOrgId()', () => {
let credentials;
let orgId;
let webex;
beforeEach(() => {
webex = new MockWebex();
credentials = new Credentials(undefined, {parent: webex});
});
it('should return the OrgId of JWT-authenticated user', () => {
credentials.supertoken = {
access_token:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJyZWFsbSI6Im15LXJlYWxtIn0.U16gzUsaRW1VVikJA2VeXRHPX716tG1_B42oxzy1UMk',
};
orgId = 'my-realm';
assert.equal(credentials.getOrgId(), orgId);
});
it('should return the OrgId of a user-token-authenticated user', () => {
orgId = 'this-is-an-org-id';
credentials.supertoken = {
access_token: `000_000_${orgId}`,
};
assert.equal(credentials.getOrgId(), orgId);
});
it('should throw if the OrgId was not determined', () =>
expect(() => credentials.getOrgId()).toThrow('the provided token is not a valid format'));
});
describe('#extractOrgIdFromJWT()', () => {
let credentials;
let webex;
beforeEach(() => {
webex = new MockWebex();
credentials = new Credentials(undefined, {parent: webex});
});
it('should return the OrgId of a provided JWT', () => {
const jwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJyZWFsbSI6Im15LXJlYWxtIn0.U16gzUsaRW1VVikJA2VeXRHPX716tG1_B42oxzy1UMk';
const realm = 'my-realm';
assert.equal(credentials.extractOrgIdFromJWT(jwt), realm);
});
it('should throw if the provided JWT is not valid', () =>
expect(() => credentials.extractOrgIdFromJWT('not-valid')).toThrow());
it('should throw if the provided JWT does not contain an OrgId', () => {
const jwtNoOrg =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
expect(() => credentials.extractOrgIdFromJWT(jwtNoOrg)).toThrow();
});
it('should throw if no JWT was provided', () =>
expect(() => credentials.extractOrgIdFromJWT()).toThrow());
});
describe('#extractOrgIdFromUserToken()', () => {
let credentials;
let webex;
beforeEach(() => {
webex = new MockWebex();
credentials = new Credentials(undefined, {parent: webex});
});
it('should return the OrgId of the provided user token', () => {
const orgId = 'this-is-an-org-id';
const userToken = `000_000_${orgId}`;
assert.equal(credentials.extractOrgIdFromUserToken(userToken), orgId);
});
it('should throw when provided an invalid token', () =>
expect(() => credentials.extractOrgIdFromUserToken()).toThrow('the provided token is not a valid format, token has 1 sections'));
it('should throw when no token is provided', () =>
expect(() => credentials.extractOrgIdFromUserToken()).toThrow());
});
describe('#initialize()', () => {
it('turns a portal auth string into a configuration object', () => {
const webex = new MockWebex();
webex.config.credentials = {
client_id: 'ci',
redirect_uri: 'ru',
scope: 's',
};
let credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
webex.trigger('change:config');
assert.equal(webex.config.credentials.client_id, 'ci');
assert.equal(credentials.config.client_id, 'ci');
assert.equal(webex.config.credentials.redirect_uri, 'ru');
assert.equal(credentials.config.redirect_uri, 'ru');
assert.equal(webex.config.credentials.scope, 's');
assert.equal(credentials.config.scope, 's');
// Accept a portal auth string via environment variables
webex.config.credentials.authorizationString = AUTHORIZATION_STRING;
credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
webex.trigger('change:config');
assert.equal(webex.config.credentials.client_id, 'MOCK_CLIENT_ID');
assert.equal(credentials.config.client_id, 'MOCK_CLIENT_ID');
assert.equal(webex.config.credentials.redirect_uri, 'http://localhost:8000');
assert.equal(credentials.config.redirect_uri, 'http://localhost:8000');
assert.equal(webex.config.credentials.scope, 'spark:rooms_read spark:teams_read');
assert.equal(credentials.config.scope, 'spark:rooms_read spark:teams_read');
});
[
'data',
'data.access_token',
'data.supertoken',
'data.supertoken.access_token',
'data.authorization',
'data.authorization.supertoken',
'data.authorization.supertoken.access_token',
]
.reduce(
(acc, path) =>
acc.concat(
['ST', 'Bearer ST'].map((str) => {
const obj = {
msg: `accepts token string "${str}" at path "${path
.split('.')
.slice(1)
.join('.')}"`,
};
set(obj, path, str);
return obj;
})
),
[]
)
.forEach(({msg, data}) => {
it(msg, () => {
const webex = new MockWebex();
const credentials = new Credentials(data, {parent: webex});
assert.isTrue(credentials.canAuthorize);
assert.equal(credentials.supertoken.access_token, 'ST');
assert.equal(credentials.supertoken.token_type, 'Bearer');
});
});
it('schedules a refreshTimer', () => {
const webex = new MockWebex({
children: {
metrics: Metrics,
},
});
const supertoken = makeToken(webex, {
access_token: 'ST',
refresh_token: 'RT',
expires: Date.now() + 10000,
});
const supertoken2 = makeToken(webex, {
access_token: 'ST2',
refresh_token: 'RT2',
expires: Date.now() + 20000,
});
sinon.stub(supertoken, 'refresh').returns(Promise.resolve(supertoken2));
sinon.stub(webex.internal.metrics, 'submitClientMetrics').callsFake(() => {});
const credentials = new Credentials(supertoken, {parent: webex});
webex.trigger('change:config');
const firstTimer = credentials.refreshTimer;
assert.isDefined(firstTimer);
assert.notCalled(supertoken.refresh);
clock.tick(10000);
return promiseTick(8)
.then(() => assert.called(supertoken.refresh))
.then(() => assert.isDefined(credentials.refreshTimer))
.then(() => assert.notEqual(credentials.refreshTimer, firstTimer));
});
it.skip('does not schedule a refreshTimer', () => {
const webex = new MockWebex();
const supertoken = makeToken(webex, {
access_token: 'ST',
refresh_token: 'RT',
expires: Date.now() - 10000,
});
sinon.stub(supertoken, 'refresh').returns(Promise.reject());
const credentials = new Credentials(supertoken, {parent: webex});
webex.trigger('change:config');
assert.isUndefined(credentials.refreshTimer);
});
});
describe('#getUserToken()', () => {
it('resolves with the supertoken if the supertoken matches the requested scopes', () => {
const webex = new MockWebex();
const credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
const st = makeToken(webex, {access_token: 'ST', scope: 'scope1'});
credentials.set({
supertoken: st,
});
return credentials.getUserToken('scope1').then((result) => assert.deepEqual(result, st));
});
it('resolves with the token identified by the specified scopes', () => {
const webex = new MockWebex();
const credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
const st = makeToken(webex, {access_token: 'ST'});
const t1 = makeToken(webex, {
access_token: 'AT1',
scope: 'scope1',
});
const t2 = makeToken(webex, {
access_token: 'AT2',
scope: 'scope2',
});
credentials.set({
supertoken: st,
userTokens: [t1, t2],
});
return Promise.all([
credentials.getUserToken('scope1').then((result) => assert.deepEqual(result, t1)),
credentials.getUserToken('scope2').then((result) => assert.deepEqual(result, t2)),
]);
});
it('uses the supertoken.scope instead of the config.scope for downscope', () => {
const webex = new MockWebex();
const credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
const st = makeToken(webex, {access_token: 'ST', scope: 'scope1 spark:kms'});
credentials.set({
supertoken: st,
scope: 'invalidScope scope1',
});
sinon.stub(credentials, 'downscope').returns(Promise.resolve());
return credentials.getUserToken().then(() => {
assert.calledWith(credentials.downscope, 'scope1');
});
});
describe('when no matching token is found', () => {
it('downscopes the supertoken', () => {
const webex = new MockWebex();
const credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
credentials.supertoken = makeToken(webex, {
access_token: 'ST',
});
const t2 = makeToken(webex, {
access_token: 'AT2',
});
sinon.stub(credentials.supertoken, 'downscope').returns(Promise.resolve(t2));
const t1 = makeToken(webex, {
access_token: 'AT1',
scope: 'scope1',
});
credentials.set({
userTokens: [t1],
});
return credentials
.getUserToken('scope2')
.then((result) => assert.deepEqual(result, t2))
.then(() => assert.calledWith(credentials.supertoken.downscope, 'scope2'));
});
});
describe('when no scope is specified', () => {
it('resolves with a token containing all but the kms scopes', () => {
const webex = new MockWebex();
const credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
credentials.supertoken = makeToken(webex, {
access_token: 'ST',
scope: 'scope1 spark:kms',
});
// const t2 = makeToken(webex, {
// access_token: `AT2`
// });
// sinon.stub(credentials.supertoken, `downscope`).returns(Promise.resolve(t2));
const t1 = makeToken(webex, {
access_token: 'AT1',
scope: 'scope1',
});
credentials.set({
userTokens: [t1],
});
return credentials.getUserToken().then((result) => assert.deepEqual(result, t1));
});
});
describe('when the kms downscope request fails', () => {
it('falls back to the supertoken', () => {
const webex = new MockWebex({
children: {
logger: Logger,
metrics: Metrics,
},
});
webex.config.metrics = config.metrics;
webex.config.credentials.scope = 'scope1 spark:kms';
const credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
credentials.supertoken = makeToken(webex, {
access_token: 'ST',
});
const failReason = 'downscope failed';
sinon.stub(credentials.supertoken, 'downscope').returns(Promise.reject(failReason));
sinon.stub(credentials.logger, 'warn').callsFake(() => {});
sinon.stub(webex.internal.metrics, 'submitClientMetrics').callsFake(() => {});
const t1 = makeToken(webex, {
access_token: 'AT1',
scope: 'scope1',
});
credentials.set({
userTokens: [t1],
});
return credentials.getUserToken('scope2').then((t) => {
assert.equal(t.access_token, credentials.supertoken.access_token);
assert.calledWith(
credentials.logger.warn,
'credentials: failed to downscope supertoken to "scope2"'
);
assert.calledWith(
webex.internal.metrics.submitClientMetrics,
'JS_SDK_CREDENTIALS_DOWNSCOPE_FAILED',
{fields: {failReason, requestedScope: 'scope2'}}
);
});
});
});
it('is blocked while a token refresh is inflight', () => {
const webex = new MockWebex({
children: {
metrics: Metrics,
},
});
webex.config.credentials.scope = 'scope1 spark:kms';
const credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
const supertoken1 = makeToken(webex, {
access_token: 'ST1',
refresh_token: 'RT1',
});
credentials.set({supertoken: supertoken1});
sinon
.stub(supertoken1, 'downscope')
.returns(Promise.resolve(new Token({access_token: 'ST1ATD'})));
const supertoken2 = makeToken(webex, {
access_token: 'ST2',
});
sinon.stub(supertoken1, 'refresh').returns(Promise.resolve(supertoken2));
const at2 = makeToken(webex, {access_token: 'ST2ATD'});
sinon.stub(supertoken2, 'downscope').returns(Promise.resolve(at2));
sinon.stub(webex.internal.metrics, 'submitClientMetrics').callsFake(() => {});
return Promise.all([
credentials.refresh(),
credentials.getUserToken('scope2').then((result) => assert.deepEqual(result, at2)),
]);
});
});
describe('#invalidate()', () => {
it('clears the refreshTimer', () => {
const webex = new MockWebex();
const credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
const st = makeToken(webex, {
access_token: 'ST',
refresh_token: 'RT',
});
const st2 = makeToken(webex, {
access_token: 'ST2',
refresh_token: 'RT2',
});
credentials.set({
supertoken: st,
});
sinon.stub(credentials, 'refresh').returns(Promise.resolve(st2));
credentials.scheduleRefresh(Date.now() + 10000);
assert.isDefined(credentials.refreshTimer);
assert.notCalled(credentials.refresh);
return credentials.invalidate().then(() => {
clock.tick(10000);
assert.isUndefined(credentials.refreshTimer);
assert.notCalled(credentials.refresh);
});
});
it('clears the tokens from boundedStorage', () => {
const webex = new MockWebex();
const credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
const st = makeToken(webex, {
access_token: 'ST',
});
const t1 = makeToken(webex, {
access_token: 'AT1',
scope: 'scope1',
});
const t2 = makeToken(webex, {
access_token: 'AT2',
scope: 'scope2',
});
credentials.set({
supertoken: st,
userTokens: [t1, t2],
});
return new Promise((resolve) => {
setTimeout(resolve, 1);
clock.tick(1000);
})
.then(() => webex.boundedStorage.get('Credentials', '@'))
.then((data) => {
assert.equal(data.userTokens[0].access_token, t1.access_token);
assert.equal(data.userTokens[1].access_token, t2.access_token);
return credentials.invalidate();
})
.then(() => promiseTick(500))
.then(
() =>
new Promise((resolve) => {
setTimeout(resolve, 1);
clock.tick(1000);
})
)
.then(() => promiseTick(500))
.then(
() =>
new Promise((resolve) => {
setTimeout(resolve, 1);
clock.tick(1000);
})
)
.then(() => assert.isRejected(webex.boundedStorage.get('Credentials', '@'), /NotFound/));
});
// it('does not induce any token refreshes');
it('prevents #getUserToken() from being invoked', () => {
const webex = new MockWebex();
const credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
const st = makeToken(webex, {
access_token: 'ST',
refresh_token: 'RT',
});
const t1 = makeToken(webex, {
access_token: 'AT1',
scope: 'scope1',
});
credentials.set({
supertoken: st,
userTokens: [t1],
});
return credentials
.invalidate()
.then(() =>
assert.isRejected(
credentials.getUserToken(),
/Current state cannot produce an access token/
)
);
});
});
describe('#refresh()', () => {
it('refreshes and downscopes the supertoken, and revokes previous tokens', () => {
const webex = new MockWebex({
children: {
metrics: Metrics,
},
});
const credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
const st = makeToken(webex, {
access_token: 'ST',
refresh_token: 'RT',
scope: 'scope1 scope2',
});
const st2 = makeToken(webex, {
access_token: 'ST2',
refresh_token: 'RT2',
scope: 'scope1 scope2',
});
const t1 = makeToken(webex, {
access_token: 'AT1',
scope: 'scope1',
});
const t2 = makeToken(webex, {
access_token: 'AT2',
scope: 'scope2',
});
sinon.stub(st2, 'downscope').returns(Promise.resolve(t2));
sinon.stub(st, 'refresh').returns(Promise.resolve(st2));
sinon.stub(t1, 'revoke').returns(Promise.resolve());
sinon.spy(credentials, 'scheduleRefresh');
sinon.stub(webex.internal.metrics, 'submitClientMetrics').callsFake(() => {});
credentials.set({
supertoken: st,
userTokens: [t1],
});
assert.equal(credentials.userTokens.get(t1.scope), t1);
return credentials
.refresh()
.then(() => assert.called(st.refresh))
.then(() => assert.calledWith(st2.downscope, 'scope1'))
.then(() => assert.called(t1.revoke))
.then(() => assert.isUndefined(credentials.userTokens.get(t1.scope)))
.then(() => assert.equal(credentials.userTokens.get(t2.scope), t2))
.then(() => assert.calledWith(credentials.scheduleRefresh, st.expires));
});
it('refreshes and downscopes the supertoken even if revocation of previous token fails', () => {
const webex = new MockWebex({
children: {
metrics: Metrics,
},
});
const credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
const st = makeToken(webex, {
access_token: 'ST',
refresh_token: 'RT',
});
const st2 = makeToken(webex, {
access_token: 'ST2',
refresh_token: 'RT2',
scope: 'scope1 scope2',
});
const t1 = makeToken(webex, {
access_token: 'AT1',
scope: 'scope1',
});
const t2 = makeToken(webex, {
access_token: 'AT2',
scope: 'scope2',
});
sinon.stub(st2, 'downscope').returns(Promise.resolve(t2));
sinon.stub(st, 'refresh').returns(Promise.resolve(st2));
sinon.stub(t1, 'revoke').returns(Promise.reject());
sinon.spy(credentials, 'scheduleRefresh');
sinon.stub(webex.internal.metrics, 'submitClientMetrics').callsFake(() => {});
credentials.set({
supertoken: st,
userTokens: [t1],
});
assert.equal(credentials.userTokens.get(t1.scope), t1);
return credentials
.refresh()
.then(() => assert.called(st.refresh))
.then(() => assert.calledWith(st2.downscope, 'scope1'))
.then(() => assert.called(t1.revoke))
.then(() => assert.isUndefined(credentials.userTokens.get(t1.scope)))
.then(() => assert.equal(credentials.userTokens.get(t2.scope), t2))
.then(() => assert.calledWith(credentials.scheduleRefresh, st.expires));
});
it('removes and revokes all child tokens', () => {
const webex = new MockWebex({
children: {
logger: Logger,
metrics: Metrics,
},
});
const credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
const st = makeToken(webex, {
access_token: 'ST',
refresh_token: 'RT',
scope: '',
});
sinon.stub(st, 'refresh').returns(Promise.resolve(makeToken(webex, {access_token: 'ST2'})));
sinon.stub(webex.internal.metrics, 'submitClientMetrics').callsFake(() => {});
const t1 = makeToken(webex, {
access_token: 'AT1',
scope: 'scope1',
});
credentials.set({
supertoken: st,
userTokens: [t1],
});
return credentials.refresh().then(() => assert.called(st.refresh));
});
it('allows #getUserToken() to be revoked, but #getUserToken() promises will not resolve until the suport token has been refreshed', () => {
const webex = new MockWebex({children: {metrics: Metrics}});
const credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
const st1 = makeToken(webex, {
access_token: 'ST1',
refresh_token: 'RT1',
});
const st2 = makeToken(webex, {
access_token: 'ST2',
refresh_token: 'RT1',
});
const t1 = makeToken(webex, {
access_token: 'AT1',
scope: 'scope1',
});
const t2 = makeToken(webex, {
access_token: 'AT2',
scope: 'scope1',
});
sinon.stub(st1, 'refresh').returns(Promise.resolve(st2));
sinon.stub(st2, 'downscope').returns(Promise.resolve(t2));
sinon.stub(webex.internal.metrics, 'submitClientMetrics').callsFake(() => {});
credentials.set({
supertoken: st1,
userTokens: [t1],
});
credentials.refresh();
return credentials.getUserToken('scope1').then((result) => assert.deepEqual(result, t2));
});
it('emits InvalidRequestError when the refresh token and access token expire', () => {
const webex = new MockWebex();
const credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
const st = makeToken(webex, {
access_token: 'ST',
refresh_token: 'RT',
});
const t1 = makeToken(webex, {
access_token: 'AT1',
scope: 'scope1',
});
const res = {
body: {
error: 'invalid_request',
error_description:
'The refresh token provided is expired, revoked, malformed, or invalid.',
trackingID: 'test123',
},
};
const ErrorConstructor = grantErrors.select(res.body.error);
sinon
.stub(st, 'refresh')
.returns(Promise.reject(new ErrorConstructor('InvalidRequestError')));
sinon.stub(credentials, 'unset').returns(Promise.resolve());
const triggerSpy = sinon.spy(webex, 'trigger');
credentials.set({
supertoken: st,
userTokens: [t1],
});
return credentials
.refresh()
.then(() => assert.called(st.refresh))
.catch(() => {
assert.called(credentials.unset);
assert.calledWith(triggerSpy, sinon.match('client:InvalidRequestError'));
});
});
it('exclude invalid scopes from user token, log and call metrics when fetched supertoken scope mismatch with the configured scope', () => {
const webex = new MockWebex({
children: {
logger: Logger,
metrics: Metrics,
},
});
const credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
const st = makeToken(webex, {
access_token: 'ST',
refresh_token: 'RT',
});
const st2 = makeToken(webex, {
access_token: 'ST2',
refresh_token: 'RT2',
scope: 'scope1',
});
const userToken = makeToken(webex, {
access_token: 'AT1',
scope: 'scope1 invalidScope1',
});
credentials.set({
supertoken: st,
userTokens: [userToken],
});
const invalidScopes = 'invalidScope1 invalidScope2';
credentials.config.scope = `scope1 ${invalidScopes}`;
sinon.stub(st2, 'downscope').returns(Promise.resolve());
sinon.stub(st, 'refresh').returns(Promise.resolve(st2));
sinon.spy(credentials, 'downscope');
sinon.spy(credentials, 'scheduleRefresh');
sinon.stub(credentials.logger, 'warn').callsFake(() => {});
sinon.stub(webex.internal.metrics, 'submitClientMetrics').callsFake(() => {});
return credentials.refresh().then(() => {
assert.calledWith(
credentials.logger.warn,
`credentials: "${invalidScopes}" scope(s) are invalid because not listed in the supertoken, they will be excluded from user token requests.`
);
assert.calledWith(
webex.internal.metrics.submitClientMetrics,
'JS_SDK_CREDENTIALS_TOKEN_REFRESH_SCOPE_MISMATCH',
{fields: {invalidScopes}}
);
assert.calledWith(credentials.downscope, 'scope1');
});
});
});
describe('#scheduleRefresh()', () => {
it('refreshes token immediately if token is expired', () => {
const webex = new MockWebex();
const credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
const st = makeToken(webex, {
access_token: 'ST',
refresh_token: 'RT',
});
const st2 = makeToken(webex, {
access_token: 'ST2',
refresh_token: 'RT2',
});
credentials.set({
supertoken: st,
});
sinon.stub(credentials, 'refresh').returns(Promise.resolve(st2));
credentials.scheduleRefresh(Date.now() - 10000);
assert.isUndefined(credentials.refreshTimer);
assert.called(credentials.refresh);
});
it('schedules a token refresh', () => {
const webex = new MockWebex();
const credentials = new Credentials(undefined, {parent: webex});
webex.trigger('change:config');
const st = makeToken(webex, {
access_token: 'ST',
refresh_token: 'RT',
});
const st2 = makeToken(webex, {
access_token: 'ST2',
refresh_token: 'RT2',
});
credentials.set({
supertoken: st,
});
sinon.stub(credentials, 'refresh').returns(Promise.resolve(st2));
credentials.scheduleRefresh(Date.now() + 10000);
assert.isDefined(credentials.refreshTimer);
assert.notCalled(credentials.refresh);
clock.tick(10000);
assert.called(credentials.refresh);
});
});
});
});