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,16 @@ const Authorization = WebexPlugin.extend({

namespace: 'Credentials',


/**
* Stores the interval ID for QR code polling
* @instance
* @memberof AuthorizationBrowserFirstParty
* @type {?number}
* @private
*/
pollingRequest: null,

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

/**
* Get an OAuth Login URL for QRCode. Generate QR code based on the returned URL.
* @instance
* @memberof AuthorizationBrowserFirstParty
* @throws {Error} When the request fails
* @returns {Promise<{verification_uri_complete: string, verification_uri: string, user_code: string, device_code: string, interval: number, expires_in: number}>}
*/
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));
});
},

/**
* Polling the server to check whether the user has completed authorization
* @instance
* @memberof AuthorizationBrowserFirstParty
* @param {Object} options
* @throws {Error} When the request fails
* @returns {Promise}
*/
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('Authorization timed out'));
return;
}
// 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 AuthorizationBrowserFirstParty
* @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,209 @@ 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: 428, body: {error: 'authorization_pending'}});
webex.request.onSecondCall().rejects({statusCode: 400, body: {error: 'slow_down'}});
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();
});
xinhyao marked this conversation as resolved.
Show resolved Hide resolved

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'}});
sinon.spy(webex.authorization, 'cancelQRCodePolling');

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

await assert.isRejected(promise, /Authorization timed out/);
assert.calledOnce(webex.authorization.cancelQRCodePolling);
clock.restore();
});
});

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

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

webex.authorization.startQRCodePolling(options);
// First poll
clock.tick(2000);
assert.calledOnce(webex.request);

webex.authorization.cancelQRCodePolling();
// Wait for next interval
clock.tick(2000);

// Verify no additional requests were made
assert.calledOnce(webex.request);
clock.restore();
});
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);
});

});

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