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

feat(automotive): qr code login flow for automotive app #3974

Open
wants to merge 10 commits into
base: next
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ const Authorization = WebexPlugin.extend({

namespace: 'Credentials',

pollingRequest: null,

/**
* Initializer
* @instance
Expand Down Expand Up @@ -240,6 +242,128 @@ const Authorization = WebexPlugin.extend({
});
},

/**
* Get an OAuth Login URL for QRCode. Generate QR code based on the returned URL.
* @instance
* @memberof Credentials
* @returns {Object}
*/
getQRCodeLoginDetails() {
return this.webex
.request({
method: 'POST',
service: 'oauth-helper',
resource: '/actions/device/authorize',
form: {
client_id: this.config.client_id,
scope: this.config.scope,
},
auth: {
user: this.config.client_id,
pass: this.config.client_secret,
sendImmediately: true,
},
maxinteger marked this conversation as resolved.
Show resolved Hide resolved
})
.then((res) => {
return res.body;
})
.catch((res) => {
if (res.statusCode !== 400) {
return Promise.reject(res);
}

const ErrorConstructor = grantErrors.select(res.body.error);

return Promise.reject(new ErrorConstructor(res._res || res));
});
},
xinhyao marked this conversation as resolved.
Show resolved Hide resolved

/**
* Polling the server to check whether the user has completed authorization
* @instance
* @memberof Credentials
* @param {Object} options
* @returns {Object}
*/
startQRCodePolling(options = {}) {
if (!options.device_code) {
return Promise.reject(new Error('A deviceCode is required'));
}

if (this.pollingRequest) {
return Promise.reject(new Error('There is already a polling request'));
}

const {device_code: deviceCode, interval = 2, expires_in: expiresIn = 300} = options;

let attempts = 0;
const maxAttempts = expiresIn / interval;

return new Promise((resolve, reject) => {
this.pollingRequest = setInterval(() => {
attempts += 1;

this.webex
.request({
method: 'POST',
service: 'oauth-helper',
resource: '/actions/device/token',
form: {
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code: deviceCode,
client_id: this.config.client_id,
},
auth: {
user: this.config.client_id,
pass: this.config.client_secret,
sendImmediately: true,
},
})
.then((res) => {
this.cancelQRCodePolling();

resolve(res.body);
})
.catch((res) => {
if (attempts >= maxAttempts) {
this.cancelQRCodePolling();
reject(
new Error('The current page has timed out, please refresh the page and try again.')
);
}
// if the statusCode is 428 which means that the authorization request is still pending
// as the end user hasn't yet completed the user-interaction steps. So keep polling.
if (res.statusCode === 428) {
return;
}

this.cancelQRCodePolling();

if (res.statusCode !== 400) {
reject(res);
}

const ErrorConstructor = grantErrors.select(res.body.error);

reject(new ErrorConstructor(res._res || res));
});
}, interval * 1000);
});
xinhyao marked this conversation as resolved.
Show resolved Hide resolved
},

/**
* cancel polling request
* @instance
* @memberof Credentials
* @returns {void}
*/
cancelQRCodePolling() {
if (this.pollingRequest) {
clearInterval(this.pollingRequest);
this.pollingRequest = null;
}
},

/**
* Extracts the orgId from the returned code from idbroker
* Description of how to parse the code can be found here:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,182 @@ describe('plugin-authorization-browser-first-party', () => {
});
});

describe('#getQRCodeLoginDetails()', () => {
it('should send correct request parameters to the API', () => {
const testClientId = 'test_client_id';
const testScope = 'test-scope';

const webex = makeWebex('http://example.com', undefined, undefined, {
credentials: {
client_id: testClientId,
scope: testScope,
}
});

webex.authorization.getQRCodeLoginDetails();

assert.calledOnce(webex.request);

const request = webex.request.getCall(0);

assert.equal(request.args[0].form.client_id, testClientId);
assert.equal(request.args[0].form.scope, testScope);
});

it('should use POST method and correct endpoint', () => {
const webex = makeWebex('http://example.com');
webex.authorization.getQRCodeLoginDetails();
const request = webex.request.getCall(0);
assert.equal(request.args[0].method, 'POST');
assert.equal(request.args[0].service, 'oauth-helper');
assert.equal(request.args[0].resource, '/actions/device/authorize');
});

it('should handle API error response', () => {
const webex = makeWebex('http://example.com');
webex.request.rejects(new Error('API Error'));
assert.isRejected(webex.authorization.getQRCodeLoginDetails(), /API Error/);
});
});

describe('#startQRCodePolling()', () => {
it('requires a deviceCode', () => {
const webex = makeWebex('http://example.com');
assert.isRejected(webex.authorization.startQRCodePolling({}), /A deviceCode is required/);
});

it('should send correct request parameters to the API', async () => {
const clock = sinon.useFakeTimers();
const testClientId = 'test_client_id';
const testDeviceCode = 'test-device-code';
const testInterval = 2;
const testExpiresIn = 300;

const options = {
device_code: testDeviceCode,
interval: testInterval,
expires_in: testExpiresIn,
};

const webex = makeWebex('http://example.com', undefined, undefined, {
credentials: {
client_id: testClientId,
}
});

webex.request.onFirstCall().resolves({statusCode: 200, body: {access_token: 'token'}});

sinon.spy(webex.authorization, 'cancelQRCodePolling');

const promise = webex.authorization.startQRCodePolling(options);
clock.tick(2000);
await promise;

assert.calledOnce(webex.request);

const request = webex.request.getCall(0);

assert.equal(request.args[0].form.client_id, testClientId);
assert.equal(request.args[0].form.device_code, testDeviceCode);
assert.equal(request.args[0].form.grant_type, 'urn:ietf:params:oauth:grant-type:device_code');

assert.calledOnce(webex.authorization.cancelQRCodePolling);

clock.restore();
});

it('should respect polling interval', async () => {
const clock = sinon.useFakeTimers();
const webex = makeWebex('http://example.com');
const options = {
device_code: 'test_code',
interval: 2,
expires_in: 300
};

webex.request.onFirstCall().rejects({statusCode: 428, body: {error: 'authorization_pending'}});
webex.request.onSecondCall().resolves({statusCode: 200, body: {access_token: 'token'}});
sinon.spy(webex.authorization, 'cancelQRCodePolling');

const promise = webex.authorization.startQRCodePolling(options);
clock.tick(4000);
await promise;

assert.calledTwice(webex.request);
assert.calledOnce(webex.authorization.cancelQRCodePolling);
clock.restore();
});

it('should handle slow_down response', async () => {
const clock = sinon.useFakeTimers();
const webex = makeWebex('http://example.com');
const options = {
device_code: 'test_code',
interval: 2,
expires_in: 300
};

webex.request.onFirstCall().rejects({statusCode: 400, body: {error: 'slow_down'}});
webex.request.onSecondCall().resolves({statusCode: 200, body: {access_token: 'token'}});

const promise = webex.authorization.startQRCodePolling(options);
clock.tick(4000);
await promise;

assert.calledTwice(webex.request);
clock.restore();
});

it('should timeout after expires_in seconds', async () => {
const clock = sinon.useFakeTimers();
const webex = makeWebex('http://example.com');
const options = {
device_code: 'test_code',
interval: 5,
expires_in: 10
};

webex.request.rejects({statusCode: 428, body: {error: 'authorization_pending'}});

const promise = webex.authorization.startQRCodePolling(options);
clock.tick(10000);
await promise;

assert.isRejected(promise, /The current page has timed out, please refresh the page and try again./);
clock.restore();
});
xinhyao marked this conversation as resolved.
Show resolved Hide resolved
});

describe('#cancelQRCodePolling()', () => {
it('should clear interval and reset polling request', () => {
const clock = sinon.useFakeTimers();
const webex = makeWebex('http://example.com');

const options = {
device_code: 'test_device_code',
interval: 2,
expires_in: 300,
};

webex.authorization.startQRCodePolling(options);
assert.isDefined(webex.authorization.pollingRequest);

webex.authorization.cancelQRCodePolling();
assert.isNull(webex.authorization.pollingRequest);

clock.restore();
});

it('should handle cancellation when no polling is in progress', () => {
const webex = makeWebex('http://example.com');
assert.isNull(webex.authorization.pollingRequest);

webex.authorization.cancelQRCodePolling();
assert.isNull(webex.authorization.pollingRequest);
});

});
xinhyao marked this conversation as resolved.
Show resolved Hide resolved

describe('#_generateCodeChallenge', () => {
const expectedCodeChallenge = 'code challenge';
// eslint-disable-next-line no-underscore-dangle
Expand Down
Loading